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
@@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
/** 将海外业务 Outbox 投递到国内区域事件桥。 */
@FeignClient(name = "event-bridge-service", contextId = "overseasContainerEventBridgeClient",
url = "${baiyue.firstleg.event-bridge-url:}")
url = "${CN_EVENT_BRIDGE_URL:${baiyue.firstleg.event-bridge-url:}}")
public interface EventBridgeClient {
@PostMapping("/internal/event-bridge/events")
ApiResponse<Acknowledgement> receive(
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.config;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.net.http.HttpClient;
@Component
public class LastlegRestClientFactory {
public RestClient build(RestClient.Builder builder, String baseUrl,
LastlegRestClientProperties.ClientTimeouts timeouts) {
JdkClientHttpRequestFactory requestFactory = requestFactory(timeouts);
return builder.requestFactory(requestFactory).baseUrl(baseUrl).build();
}
HttpClient httpClient(LastlegRestClientProperties.ClientTimeouts timeouts) {
return HttpClient.newBuilder()
.connectTimeout(timeouts.getConnectTimeout())
.build();
}
JdkClientHttpRequestFactory requestFactory(LastlegRestClientProperties.ClientTimeouts timeouts) {
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient(timeouts));
requestFactory.setReadTimeout(timeouts.getReadTimeout());
return requestFactory;
}
}
@@ -0,0 +1,54 @@
package com.baiyue.lastleg.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
@ConfigurationProperties(prefix = "baiyue.http.clients")
public class LastlegRestClientProperties {
private ClientTimeouts unloadPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
private ClientTimeouts relabelPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
private ClientTimeouts warehouseResource = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5));
private ClientTimeouts palletPhysical = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5));
public ClientTimeouts getUnloadPhotoFile() { return unloadPhotoFile; }
public void setUnloadPhotoFile(ClientTimeouts value) { unloadPhotoFile = required(value, "unload-photo-file"); }
public ClientTimeouts getRelabelPhotoFile() { return relabelPhotoFile; }
public void setRelabelPhotoFile(ClientTimeouts value) { relabelPhotoFile = required(value, "relabel-photo-file"); }
public ClientTimeouts getWarehouseResource() { return warehouseResource; }
public void setWarehouseResource(ClientTimeouts value) { warehouseResource = required(value, "warehouse-resource"); }
public ClientTimeouts getPalletPhysical() { return palletPhysical; }
public void setPalletPhysical(ClientTimeouts value) { palletPhysical = required(value, "pallet-physical"); }
private static ClientTimeouts required(ClientTimeouts value, String client) {
if (value == null) throw new IllegalArgumentException(client + " HTTP timeouts are required");
return value;
}
public static class ClientTimeouts {
private Duration connectTimeout;
private Duration readTimeout;
public ClientTimeouts() { }
public ClientTimeouts(Duration connectTimeout, Duration readTimeout) {
setConnectTimeout(connectTimeout);
setReadTimeout(readTimeout);
}
public Duration getConnectTimeout() { return connectTimeout; }
public void setConnectTimeout(Duration value) { connectTimeout = positive(value, "connect-timeout"); }
public Duration getReadTimeout() { return readTimeout; }
public void setReadTimeout(Duration value) { readTimeout = positive(value, "read-timeout"); }
private static Duration positive(Duration value, String name) {
if (value == null || value.isZero() || value.isNegative()) {
throw new IllegalArgumentException(name + " must be positive");
}
return value;
}
}
}
@@ -11,6 +11,8 @@ import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.security.OverseasPermissions;
import com.baiyue.lastleg.service.LastlegOrderService;
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer;
import com.baiyue.common.event.EventTypes;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@@ -37,16 +39,26 @@ import java.nio.charset.StandardCharsets;
@Tag(name = "Tail-leg orders")
public class LastlegOrderController {
@Resource private LoadingCompletedConsumer consumer;
@Resource private ContainerShippingNodeConsumer shippingNodeConsumer;
@Resource private LastlegOrderService orderService;
@Resource private InternalServiceTokenGuard tokenGuard;
@PostMapping("/internal/lastleg/events")
@Operation(summary = "Receive a first-leg loading-completed event")
@Operation(summary = "Receive a first-leg loading or actual shipping-node event",
description = "Accepts loading-completed V1/V2 and FirstLegContainerShippingNodeRecorded V1 through the internal token boundary; only monotonic actual PICKUP_READY-or-later facts open overseas PDA receiving.")
public ApiResponse<Integer> receive(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody EventEnvelope event) {
tokenGuard.verify(token);
return ApiResponse.success(consumer.consume(event));
int consumed;
if (EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType())) {
consumed = shippingNodeConsumer.consume(event);
} else if (EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(event.eventType())) {
consumed = consumer.consume(event);
} else {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Lastleg 不消费该事件类型: " + event.eventType());
}
return ApiResponse.success(consumed);
}
@GetMapping("/api/platform/v1/last-leg/orders")
@@ -11,6 +11,8 @@ public record LastlegOrderSummary(
String orderNo,
String orderType,
String orderTypeName,
@Schema(description = "跨区冻结目的类型", allowableValues = {"FBA", "OVERSEAS"})
String destinationType,
String sourceInboundId,
String inboundCode,
String sourceOrderNo,
@@ -13,7 +13,6 @@ import java.time.LocalDateTime;
@TableName("ll_inbound_order")
public class LastlegInboundOrder {
@TableId private Long id;
private String tenantId;
private String customerId;
private String sourceRegion;
private String sourceInboundId;
@@ -25,6 +24,7 @@ public class LastlegInboundOrder {
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private String destinationType;
private Integer packageCount;
private Integer productQuantity;
private Integer palletCount;
@@ -15,6 +15,7 @@ public class LastlegOrder {
private String orderNo;
private String customerId;
private String orderType;
private String destinationType;
private String inboundCode;
private String sourceRegion;
private String sourceInboundId;
@@ -23,6 +24,8 @@ public class LastlegOrder {
private String sourceOrderNo;
private String rootOrderId;
private String customerName;
private String sourceWarehouseId;
private String sourceWarehouseName;
private String overseasWarehouseId;
private String overseasWarehouseName;
private String fbaWarehouseCode;
@@ -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");
}
@@ -17,7 +17,7 @@ public final class InventoryCommandRequests {
String overseasWarehouseCode,
String overseasWarehouseName,
String destinationWarehouseId,
@NotBlank String destinationWarehouseCode,
String destinationWarehouseCode,
String destinationWarehouseName,
@NotNull Long inboundOrderId,
@NotBlank String inboundOrderNo,
@@ -44,7 +44,7 @@ public final class InventoryCommandRequests {
String overseasWarehouseCode,
String overseasWarehouseName,
String destinationWarehouseId,
@NotBlank String destinationWarehouseCode,
String destinationWarehouseCode,
String destinationWarehouseName,
@NotNull Long inboundOrderId,
@NotBlank String inboundOrderNo,
@@ -233,8 +233,14 @@ public class InventoryCommandService {
String inboundCode, String requestedSkuCode, String productNameZh,
String productNameEn) {
String skuCode = sku(requestedSkuCode);
String destinationId = trim(destinationWarehouseId);
String destinationCode = trim(destinationWarehouseCode);
if (destinationId == null && destinationCode == null) {
throw badRequest("Destination warehouse ID or code is required");
}
if (destinationCode == null) destinationCode = "";
InventoryBalance balance = balances.lockDimension(customerId, overseasWarehouseId,
destinationWarehouseCode, inboundOrderId, inboundCode);
destinationCode, inboundOrderId, inboundCode);
if (balance != null) {
mergeSkuSnapshot(balance, skuCode);
return balance;
@@ -246,8 +252,8 @@ public class InventoryCommandService {
balance.setOverseasWarehouseId(overseasWarehouseId.trim());
balance.setOverseasWarehouseCode(trim(overseasWarehouseCode));
balance.setOverseasWarehouseName(trim(overseasWarehouseName));
balance.setDestinationWarehouseId(trim(destinationWarehouseId));
balance.setDestinationWarehouseCode(destinationWarehouseCode.trim());
balance.setDestinationWarehouseId(destinationId);
balance.setDestinationWarehouseCode(destinationCode);
balance.setDestinationWarehouseName(trim(destinationWarehouseName));
balance.setInboundOrderId(inboundOrderId);
balance.setInboundOrderNo(inboundOrderNo.trim());
@@ -264,7 +270,7 @@ public class InventoryCommandService {
return balance;
} catch (DuplicateKeyException exception) {
InventoryBalance concurrent = balances.lockDimension(customerId, overseasWarehouseId,
destinationWarehouseCode, inboundOrderId, inboundCode);
destinationCode, inboundOrderId, inboundCode);
if (concurrent != null) {
mergeSkuSnapshot(concurrent, skuCode);
return concurrent;
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.inventory.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@@ -40,8 +41,11 @@ public class InventoryLedger {
private Long deltaPackages;
private Long afterPackages;
/** Quantity snapshot is retained even though current PDA operations are carton-count based. */
@TableField(exist = false)
private Long beforeQuantity;
@TableField(exist = false)
private Long deltaQuantity;
@TableField(exist = false)
private Long afterQuantity;
private String operatorId;
private String remark;
@@ -22,6 +22,7 @@ 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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@@ -40,9 +41,9 @@ public class InventoryQueryController {
@Operation(summary = "Get inbound-order package inventory totals", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<InventoryDashboardResponse> summary(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.BalanceQuery query) {
requirePlatform(user);
return ApiResponse.success(service.dashboard(null, query));
return ApiResponse.success(service.dashboard(platformScope(user, customerId), query));
}
@GetMapping(BASE_PATH + "/balances")
@@ -50,9 +51,9 @@ public class InventoryQueryController {
@Operation(summary = "Page inventory by customer, warehouse and inbound order", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<PageResult<InventoryBalanceView>> balances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.BalanceQuery query) {
requirePlatform(user);
return ApiResponse.success(service.balances(null, query));
return ApiResponse.success(service.balances(platformScope(user, customerId), query));
}
@GetMapping(BASE_PATH + "/balances/{balanceId}/positions")
@@ -61,9 +62,9 @@ public class InventoryQueryController {
public ApiResponse<List<InventoryPositionView>> positions(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long balanceId,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.PositionQuery query) {
requirePlatform(user);
return ApiResponse.success(service.positions(null, balanceId, query));
return ApiResponse.success(service.positions(platformScope(user, customerId), balanceId, query));
}
@GetMapping(BASE_PATH + "/ledger")
@@ -71,9 +72,9 @@ public class InventoryQueryController {
@Operation(summary = "Page append-only inventory movements", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<PageResult<InventoryLedgerView>> ledgers(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.LedgerQuery query) {
requirePlatform(user);
return ApiResponse.success(service.ledgers(null, query));
return ApiResponse.success(service.ledgers(platformScope(user, customerId), query));
}
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv")
@@ -81,9 +82,9 @@ public class InventoryQueryController {
@Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
public ResponseEntity<byte[]> exportBalances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.BalanceQuery query) {
requirePlatform(user);
return csv(service.exportBalances(null, query), "inventory-balances.csv");
return csv(service.exportBalances(platformScope(user, customerId), query), "inventory-balances.csv");
}
@GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv")
@@ -91,9 +92,9 @@ public class InventoryQueryController {
@Operation(summary = "Export filtered inventory movements as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
public ResponseEntity<byte[]> exportLedgers(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.LedgerQuery query) {
requirePlatform(user);
return csv(service.exportLedgers(null, query), "inventory-ledger.csv");
return csv(service.exportLedgers(platformScope(user, customerId), query), "inventory-ledger.csv");
}
private static ResponseEntity<byte[]> csv(byte[] body, String filename) {
@@ -103,12 +104,14 @@ public class InventoryQueryController {
.body(body);
}
private void requirePlatform(AuthenticatedUser user) {
private String platformScope(AuthenticatedUser user, String requestedCustomerId) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required");
}
return requestedCustomerId == null || requestedCustomerId.isBlank() || "0".equals(requestedCustomerId.trim())
? null : requestedCustomerId.trim();
}
}
@@ -89,8 +89,6 @@ public interface InventoryQueryMapper {
@Select("<script>SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId}" +
"<if test='q.palletNo != null and q.palletNo != \"\"'> AND pallet_no LIKE CONCAT('%',#{q.palletNo},'%')</if>" +
"<if test='q.batchNo != null and q.batchNo != \"\"'> AND batch_no LIKE CONCAT('%',#{q.batchNo},'%')</if>" +
"<if test='q.skuCode != null and q.skuCode != \"\"'> AND sku_code LIKE CONCAT('%',#{q.skuCode},'%')</if>" +
"<if test='q.warehouseLocationCode != null and q.warehouseLocationCode != \"\"'> AND warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%')</if>" +
"<choose>" +
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND (available_packages+allocated_packages+picked_packages) &gt; 0</when>" +
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_packages &gt; 0</when>" +
@@ -68,5 +68,6 @@ public final class LocalLooseboardRequests {
public record ChangeStatus(
@NotNull @Min(0) Long version,
@NotBlank @Size(max = 32) String status,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) { }
}
@@ -17,7 +17,7 @@ public final class LocalLooseboardResponses {
public record History(
String operationType, String fromStatus, String toStatus, String operatorId,
String changeDetail, String remark, LocalDateTime createdAt) { }
String idempotencyKey, String changeDetail, String remark, LocalDateTime createdAt) { }
public record Detail(
Summary summary, String originAddress, String destinationAddress, String remark,
@@ -17,6 +17,7 @@ public class LocalLooseboardHistory {
private String fromStatus;
private String toStatus;
private String operatorId;
private String idempotencyKey;
private String changeDetail;
private String remark;
private LocalDateTime createdAt;
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.looseboard.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_local_looseboard_operation")
public class LocalLooseboardOperation {
@TableId(type = IdType.INPUT) private Long id;
private Long orderId;
private String customerId;
private String operationType;
private String idempotencyKey;
private String requestHash;
private Long resultVersion;
private String createdBy;
private LocalDateTime createdAt;
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.looseboard.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface LocalLooseboardOperationMapper extends BaseMapper<LocalLooseboardOperation> {
@Select("SELECT * FROM ll_local_looseboard_operation WHERE customer_id=#{customerId} AND order_id=#{orderId} AND idempotency_key=#{key} LIMIT 1")
LocalLooseboardOperation findKey(@Param("customerId") String customerId,
@Param("orderId") Long orderId,
@Param("key") String key);
}
@@ -12,8 +12,10 @@ import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOperationMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -51,6 +53,7 @@ public class LocalLooseboardService {
@Resource private LocalLooseboardOrderMapper orders;
@Resource private LocalLooseboardHistoryMapper histories;
@Resource private LocalLooseboardOperationMapper operations;
@Resource private LastlegOrderMapper lastlegOrders;
@Resource private LastlegOrderStatusHistoryMapper lastlegHistories;
@Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator;
@@ -88,7 +91,8 @@ public class LocalLooseboardService {
if (concurrent != null) return replay(customerId, concurrent, requestHash);
throw exception;
}
history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId, null, request.remark(), now);
history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId,
key, null, request.remark(), now);
lastlegHistory(customerId, lastlegOrder.getId(), null, lastlegOrder.getCurrentStatus(),
"Local looseboard order submitted", now);
return detail(customerId, order.getOrderNo());
@@ -139,7 +143,7 @@ public class LocalLooseboardService {
touch(order, now);
orders.updateById(order);
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
changeDetail(before, after), null, now);
null, changeDetail(before, after), null, now);
return response(order);
}
@@ -147,6 +151,15 @@ public class LocalLooseboardService {
public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.ChangeStatus request) {
LocalLooseboardOrder order = lock(customerId, orderNo);
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
LocalLooseboardOperation existing = operations.findKey(customerId, order.getId(), key);
if (existing != null) {
if (!"STATUS".equals(existing.getOperationType()) || !requestHash.equals(existing.getRequestHash())) {
throw conflict("Idempotency key was reused with a different looseboard status payload");
}
return response(order);
}
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
String next = norm(request.status());
if (!STATUSES.contains(next)) throw badRequest("Unsupported looseboard order status");
@@ -160,7 +173,13 @@ public class LocalLooseboardService {
touch(order, now);
orders.updateById(order);
syncLastlegOrder(order, next, now);
history(order, "STATUS", from, next, operatorId, null, request.remark(), now);
LocalLooseboardOperation operation = new LocalLooseboardOperation();
operation.setId(IdWorker.getId()); operation.setOrderId(order.getId());
operation.setCustomerId(customerId); operation.setOperationType("STATUS");
operation.setIdempotencyKey(key); operation.setRequestHash(requestHash);
operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId);
operation.setCreatedAt(now); operations.insert(operation);
history(order, "STATUS", from, next, operatorId, key, null, request.remark(), now);
return response(order);
}
@@ -204,7 +223,8 @@ public class LocalLooseboardService {
order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(),
histories.listOrder(order.getCustomerId(), order.getId()).stream()
.map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(),
value.getToStatus(), value.getOperatorId(), value.getChangeDetail(), value.getRemark(),
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
value.getChangeDetail(), value.getRemark(),
value.getCreatedAt())).toList());
}
@@ -218,7 +238,7 @@ public class LocalLooseboardService {
}
private void history(LocalLooseboardOrder order, String type, String from, String to, String operator,
String changeDetail, String remark, LocalDateTime now) {
String idempotencyKey, String changeDetail, String remark, LocalDateTime now) {
LocalLooseboardHistory history = new LocalLooseboardHistory();
history.setId(IdWorker.getId());
history.setOrderId(order.getId());
@@ -227,6 +247,7 @@ public class LocalLooseboardService {
history.setFromStatus(from);
history.setToStatus(to);
history.setOperatorId(operator);
history.setIdempotencyKey(trim(idempotencyKey));
history.setChangeDetail(changeDetail);
history.setRemark(trim(remark));
history.setCreatedAt(now);
@@ -3,7 +3,11 @@ package com.baiyue.lastleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface LastlegEventOutboxMapper extends BaseMapper<LastlegEventOutbox> {
@Select("SELECT * FROM ll_event_outbox WHERE event_key=#{eventKey} LIMIT 1")
LastlegEventOutbox findByEventKey(@Param("eventKey") String eventKey);
}
@@ -35,4 +35,11 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
"AND shipped_package_count+#{packages} <= package_count")
int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id,
@Param("packages") long packages, @Param("outboundNo") String outboundNo);
@Update("UPDATE ll_order SET overseas_warehouse_id=#{warehouseId},overseas_warehouse_name=#{warehouseName}," +
"updated_at=NOW(3) WHERE id=#{orderId} AND customer_id=#{customerId} " +
"AND (overseas_warehouse_id IS NULL OR overseas_warehouse_id=#{warehouseId})")
int claimReceivingWarehouse(@Param("customerId") String customerId, @Param("orderId") long orderId,
@Param("warehouseId") String warehouseId,
@Param("warehouseName") String warehouseName);
}
@@ -23,6 +23,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
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 java.nio.charset.StandardCharsets;
@@ -54,9 +55,11 @@ public class PlatformOverseasQuoteController {
@GetMapping("/warehouse-options")
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_LIST + "')")
@Operation(summary = "List cached active overseas partner warehouse options",
description = "Required authority: platform:overseas-master-data:quote:list; the browser never calls CN internal APIs")
public ApiResponse<List<OverseasQuoteResponses.PartnerWarehouseOption>> warehouseOptions() {
return ApiResponse.success(service.warehouseOptions());
description = "Required authority: platform:overseas-master-data:quote:list; the browser never calls CN internal APIs; "
+ "refresh=true bypasses the positive cache after CN partner master data changes")
public ApiResponse<List<OverseasQuoteResponses.PartnerWarehouseOption>> warehouseOptions(
@RequestParam(defaultValue = "false") boolean refresh) {
return ApiResponse.success(service.warehouseOptions(refresh));
}
@PostMapping
@@ -73,8 +73,8 @@ public class OverseasQuoteService {
quote.getCreatedBy(), quote.getCreatedAt());
}
public List<OverseasQuoteResponses.PartnerWarehouseOption> warehouseOptions() {
return warehouses.options();
public List<OverseasQuoteResponses.PartnerWarehouseOption> warehouseOptions(boolean refresh) {
return refresh ? warehouses.refresh() : warehouses.options();
}
@Transactional
@@ -6,6 +6,7 @@ import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.Clock;
@@ -23,26 +24,39 @@ public class PartnerWarehouseDirectory {
private volatile Snapshot snapshot;
public List<OverseasQuoteResponses.PartnerWarehouseOption> options() {
return options(false);
}
/** Bypasses a still-valid positive snapshot after CN partner master data changes. */
public List<OverseasQuoteResponses.PartnerWarehouseOption> refresh() {
return options(true);
}
private List<OverseasQuoteResponses.PartnerWarehouseOption> options(boolean forceRefresh) {
Snapshot current = snapshot;
Instant now = clock.instant();
if (current != null && now.isBefore(current.expiresAt())) return current.options();
if (!forceRefresh && current != null && now.isBefore(current.expiresAt())) return current.options();
synchronized (this) {
current = snapshot;
now = clock.instant();
if (current != null && now.isBefore(current.expiresAt())) return current.options();
if (!forceRefresh && current != null && now.isBefore(current.expiresAt())) return current.options();
var response = client.activePartners(internalToken);
if (response == null || !response.successful() || response.data() == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"国内海外合作仓候选暂不可用");
}
List<OverseasQuoteResponses.PartnerWarehouseOption> options = response.data().stream()
.filter(value -> value != null && value.id() != null)
.filter(this::valid)
.map(value -> new OverseasQuoteResponses.PartnerWarehouseOption(value.id().toString(),
value.warehouseCode(), value.warehouseName(), value.countryCode()))
value.warehouseCode().trim(), value.warehouseName().trim(),
value.countryCode().trim().toUpperCase(java.util.Locale.ROOT)))
.toList();
// An empty directory can be a short-lived cross-region visibility gap after a partner
// warehouse is created. Do not preserve that absence for the normal 30-minute TTL.
if (options.isEmpty()) return List.of();
if (options.isEmpty()) {
snapshot = null;
return List.of();
}
snapshot = new Snapshot(options, now.plus(ttl));
return snapshot.options();
}
@@ -54,6 +68,14 @@ public class PartnerWarehouseDirectory {
"所选海外合作仓不存在或已停用"));
}
private boolean valid(DomesticPartnerWarehouseClient.Option value) {
return value != null && value.id() != null
&& StringUtils.hasText(value.warehouseCode())
&& StringUtils.hasText(value.warehouseName())
&& StringUtils.hasText(value.countryCode())
&& value.countryCode().trim().matches("(?i)[A-Z]{2}");
}
private record Snapshot(List<OverseasQuoteResponses.PartnerWarehouseOption> options, Instant expiresAt) {
private Snapshot { options = List.copyOf(options); }
}
@@ -0,0 +1,103 @@
package com.baiyue.lastleg.masterdata.vehicle.adapter.in.web;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.VehicleFailure;
import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.util.function.Supplier;
@RestController
@RequestMapping("/api/platform/v1/last-leg/master-data/vehicles")
@Tag(name = "Vehicle master data / 车辆主数据")
public class PlatformVehicleController {
private final VehicleApplicationService service;
public PlatformVehicleController(VehicleApplicationService service) {
this.service = service;
}
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')")
@Operation(summary = "Page vehicle master data", description = "Required authority: platform:overseas-master-data:vehicle:list")
public ApiResponse<PageResult<VehicleModels.Response>> page(@Valid VehicleModels.Query query) {
return execute(() -> ApiResponse.success(service.page(query)));
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')")
@Operation(summary = "Get vehicle master data detail", description = "Required authority: platform:overseas-master-data:vehicle:list; excludes delivery fulfillment history")
public ApiResponse<VehicleModels.Response> detail(@PathVariable long id) {
return execute(() -> ApiResponse.success(service.detail(id)));
}
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_CREATE + "')")
public ApiResponse<VehicleModels.Response> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody VehicleModels.Create request) {
return execute(() -> ApiResponse.success(service.create(operator(user), request)));
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EDIT + "')")
public ApiResponse<VehicleModels.Response> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long id,
@Valid @RequestBody VehicleModels.Update request) {
return execute(() -> ApiResponse.success(service.update(id, operator(user), request)));
}
@PatchMapping("/{id}/status")
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_STATUS + "')")
public ApiResponse<VehicleModels.Response> status(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long id,
@Valid @RequestBody VehicleModels.ChangeStatus request) {
return execute(() -> ApiResponse.success(service.changeStatus(id, operator(user), request)));
}
@GetMapping(value = "/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EXPORT + "')")
public ResponseEntity<byte[]> export(@Valid VehicleModels.Query query) {
return execute(() -> ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=vehicles.csv").body(service.exportCsv(query)));
}
private String operator(AuthenticatedUser user) {
if (user == null) return "UNKNOWN";
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
private <T> T execute(Supplier<T> action) {
try {
return action.get();
} catch (VehicleFailure failure) {
HttpStatus status = switch (failure.kind()) {
case BAD_REQUEST -> HttpStatus.BAD_REQUEST;
case NOT_FOUND -> HttpStatus.NOT_FOUND;
case CONFLICT -> HttpStatus.CONFLICT;
};
throw new ResponseStatusException(status, failure.getMessage(), failure);
}
}
}
@@ -1,13 +1,12 @@
package com.baiyue.lastleg.masterdata.vehicle.mapper;
package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface VehicleMapper extends BaseMapper<Vehicle> {
public interface VehicleMapper extends BaseMapper<VehicleRecord> {
@Select("SELECT * FROM ll_vehicle WHERE id=#{id} LIMIT 1 FOR UPDATE")
Vehicle lockById(@Param("id") long id);
VehicleRecord lockById(@Param("id") long id);
}
@@ -0,0 +1,121 @@
package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Optional;
@Repository
public class VehiclePersistenceAdapter implements VehicleRepository {
private final VehicleMapper vehicles;
public VehiclePersistenceAdapter(VehicleMapper vehicles) {
this.vehicles = vehicles;
}
@Override
public PageSlice page(SearchCriteria criteria) {
Page<VehicleRecord> page = vehicles.selectPage(Page.of(criteria.page(), criteria.size()), wrapper(criteria));
return new PageSlice(page.getRecords().stream().map(this::snapshot).toList(), page.getCurrent(),
page.getSize(), page.getTotal(), page.getPages());
}
@Override
public List<VehicleSnapshot> list(SearchCriteria criteria) {
return vehicles.selectList(wrapper(criteria)).stream().map(this::snapshot).toList();
}
@Override
public Optional<VehicleSnapshot> findById(long id) {
return Optional.ofNullable(vehicles.selectById(id)).map(this::snapshot);
}
@Override
public Optional<VehicleSnapshot> lockById(long id) {
return Optional.ofNullable(vehicles.lockById(id)).map(this::snapshot);
}
@Override
public long create(NewVehicle vehicle) {
VehicleRecord record = new VehicleRecord();
record.setId(IdWorker.getId());
record.setPlateNumber(vehicle.plateNumber());
record.setModel(vehicle.model());
record.setFuelConsumptionPer100Km(vehicle.fuelConsumptionPer100Km());
record.setMileageKm(vehicle.mileageKm());
record.setDriverOrDispatchCompany(vehicle.driverOrDispatchCompany());
record.setPhone(vehicle.phone());
record.setRegion(vehicle.region());
record.setOwnership(vehicle.ownership());
record.setStatus(vehicle.status());
record.setRemark(vehicle.remark());
record.setRecordVersion(0L);
record.setCreatedBy(vehicle.operator());
record.setUpdatedBy(vehicle.operator());
record.setCreatedAt(vehicle.createdAt());
record.setUpdatedAt(vehicle.createdAt());
try {
vehicles.insert(record);
} catch (DuplicateKeyException exception) {
throw new DuplicatePlateException(exception);
}
return record.getId();
}
@Override
public boolean update(long id, long expectedVersion, VehicleChanges changes) {
try {
return vehicles.update(null, new LambdaUpdateWrapper<VehicleRecord>()
.eq(VehicleRecord::getId, id).eq(VehicleRecord::getRecordVersion, expectedVersion)
.set(VehicleRecord::getPlateNumber, changes.plateNumber())
.set(VehicleRecord::getModel, changes.model())
.set(VehicleRecord::getFuelConsumptionPer100Km, changes.fuelConsumptionPer100Km())
.set(VehicleRecord::getMileageKm, changes.mileageKm())
.set(VehicleRecord::getDriverOrDispatchCompany, changes.driverOrDispatchCompany())
.set(VehicleRecord::getPhone, changes.phone()).set(VehicleRecord::getRegion, changes.region())
.set(VehicleRecord::getOwnership, changes.ownership()).set(VehicleRecord::getRemark, changes.remark())
.set(VehicleRecord::getUpdatedBy, changes.operator()).set(VehicleRecord::getUpdatedAt, changes.updatedAt())
.setSql("record_version = record_version + 1")) == 1;
} catch (DuplicateKeyException exception) {
throw new DuplicatePlateException(exception);
}
}
@Override
public boolean updateStatus(long id, long expectedVersion, String status, String operator,
java.time.LocalDateTime updatedAt) {
return vehicles.update(null, new LambdaUpdateWrapper<VehicleRecord>()
.eq(VehicleRecord::getId, id).eq(VehicleRecord::getRecordVersion, expectedVersion)
.set(VehicleRecord::getStatus, status).set(VehicleRecord::getUpdatedBy, operator)
.set(VehicleRecord::getUpdatedAt, updatedAt).setSql("record_version = record_version + 1")) == 1;
}
private LambdaQueryWrapper<VehicleRecord> wrapper(SearchCriteria criteria) {
return new LambdaQueryWrapper<VehicleRecord>()
.and(StringUtils.hasText(criteria.keyword()), wrapper -> wrapper.like(VehicleRecord::getPlateNumber, criteria.keyword())
.or().like(VehicleRecord::getModel, criteria.keyword())
.or().like(VehicleRecord::getDriverOrDispatchCompany, criteria.keyword()))
.eq(criteria.ownership() != null, VehicleRecord::getOwnership, criteria.ownership())
.eq(criteria.region() != null, VehicleRecord::getRegion, criteria.region())
.eq(criteria.status() != null, VehicleRecord::getStatus, criteria.status())
.orderByDesc(VehicleRecord::getUpdatedAt).orderByDesc(VehicleRecord::getId);
}
private VehicleSnapshot snapshot(VehicleRecord value) {
return new VehicleSnapshot(value.getId(), value.getPlateNumber(), value.getModel(),
value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(),
value.getRegion(), value.getOwnership(), value.getStatus(), value.getRemark(), version(value.getRecordVersion()),
value.getUpdatedBy(), value.getUpdatedAt());
}
private static long version(Long value) {
return value == null ? 0L : value;
}
}
@@ -1,4 +1,4 @@
package com.baiyue.lastleg.masterdata.vehicle.entity;
package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -10,7 +10,7 @@ import java.time.LocalDateTime;
@Data
@TableName("ll_vehicle")
public class Vehicle {
public class VehicleRecord {
@TableId(type = IdType.INPUT) private Long id;
private String plateNumber;
private String model;
@@ -0,0 +1,165 @@
package com.baiyue.lastleg.masterdata.vehicle.application;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.DuplicatePlateException;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.NewVehicle;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.SearchCriteria;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleChanges;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleSnapshot;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Set;
@Service
public class VehicleApplicationService {
private static final Set<String> OWNERSHIPS = Set.of("OWNED", "DISPATCHED");
private static final Set<String> REGIONS = Set.of("DOMESTIC", "OVERSEAS");
private static final Set<String> STATUSES = Set.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED");
private final VehicleRepository vehicles;
public VehicleApplicationService(VehicleRepository vehicles) {
this.vehicles = vehicles;
}
public PageResult<VehicleModels.Response> page(VehicleModels.Query query) {
var page = vehicles.page(criteria(query));
return new PageResult<>(page.records().stream().map(this::response).toList(), page.page(),
page.size(), page.total(), page.pages());
}
public VehicleModels.Response detail(long id) {
return response(required(id));
}
@Transactional
public VehicleModels.Response create(String operator, VehicleModels.Create request) {
LocalDateTime now = LocalDateTime.now();
long id;
try {
id = vehicles.create(new NewVehicle(
requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT),
requiredText(request.model(), "车型"), request.fuelConsumptionPer100Km(), request.mileageKm(),
trim(request.driverOrDispatchCompany()), trim(request.phone()), region(request.region()),
ownership(request.ownership()), status(request.status()), trim(request.remark()), operator, now));
} catch (DuplicatePlateException exception) {
throw conflict("车牌号已存在");
}
return detail(id);
}
@Transactional
public VehicleModels.Response update(long id, String operator, VehicleModels.Update request) {
VehicleSnapshot current = locked(id);
requireVersion(current, request.recordVersion());
if (request.mileageKm() < current.mileageKm()) throw badRequest("当前公里码不能小于已有公里码");
boolean updated;
try {
updated = vehicles.update(id, request.recordVersion(), new VehicleChanges(
requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT),
requiredText(request.model(), "车型"), request.fuelConsumptionPer100Km(), request.mileageKm(),
trim(request.driverOrDispatchCompany()), trim(request.phone()), region(request.region()),
ownership(request.ownership()), trim(request.remark()), operator, LocalDateTime.now()));
} catch (DuplicatePlateException exception) {
throw conflict("车牌号已存在");
}
if (!updated) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
return detail(id);
}
@Transactional
public VehicleModels.Response changeStatus(long id, String operator, VehicleModels.ChangeStatus request) {
VehicleSnapshot current = locked(id);
requireVersion(current, request.recordVersion());
String status = status(request.status());
if (status.equals(current.status())) return response(current);
boolean updated = vehicles.updateStatus(id, request.recordVersion(), status, operator, LocalDateTime.now());
if (!updated) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
return detail(id);
}
public byte[] exportCsv(VehicleModels.Query query) {
var rows = vehicles.list(criteria(query)).stream().map(this::response).toList();
StringBuilder csv = new StringBuilder("\uFEFF车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n");
for (var row : rows) csv.append(row.id()).append(',').append(cell(row.plateNumber())).append(',')
.append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',')
.append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',')
.append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',')
.append(row.updatedAt()).append("\r\n");
return csv.toString().getBytes(StandardCharsets.UTF_8);
}
private SearchCriteria criteria(VehicleModels.Query query) {
return new SearchCriteria(query.page(), query.size(), trim(query.keyword()),
optional(query.ownership(), OWNERSHIPS, "车辆归属"),
optional(query.region(), REGIONS, "车辆区域"),
optional(query.status(), STATUSES, "车辆状态"));
}
private VehicleSnapshot required(long id) {
return vehicles.findById(id).orElseThrow(VehicleApplicationService::notFound);
}
private VehicleSnapshot locked(long id) {
return vehicles.lockById(id).orElseThrow(VehicleApplicationService::notFound);
}
private VehicleModels.Response response(VehicleSnapshot value) {
return new VehicleModels.Response(String.valueOf(value.id()), value.plateNumber(), value.model(),
value.fuelConsumptionPer100Km(), value.mileageKm(), value.driverOrDispatchCompany(), value.phone(),
value.region(), value.ownership(), value.status(), value.remark(), value.recordVersion(),
value.updatedBy(), value.updatedAt());
}
private void requireVersion(VehicleSnapshot value, long expected) {
if (value.recordVersion() != expected) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
}
private static String ownership(String value) { return required(value, OWNERSHIPS, "车辆归属"); }
private static String region(String value) { return required(value, REGIONS, "车辆区域"); }
private static String status(String value) { return required(value, STATUSES, "车辆状态"); }
private static String optional(String value, Set<String> allowed, String label) {
return StringUtils.hasText(value) ? required(value, allowed, label) : null;
}
private static String required(String value, Set<String> allowed, String label) {
if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空");
String normalized = value.trim().toUpperCase(Locale.ROOT);
if (!allowed.contains(normalized)) throw badRequest(label + "不合法");
return normalized;
}
private static String requiredText(String value, String label) {
if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空");
return value.trim();
}
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
private static VehicleFailure notFound() { return new VehicleFailure(FailureKind.NOT_FOUND, "车辆档案不存在"); }
private static VehicleFailure badRequest(String message) { return new VehicleFailure(FailureKind.BAD_REQUEST, message); }
private static VehicleFailure conflict(String message) { return new VehicleFailure(FailureKind.CONFLICT, message); }
public enum FailureKind { BAD_REQUEST, NOT_FOUND, CONFLICT }
public static final class VehicleFailure extends RuntimeException {
private final FailureKind kind;
public VehicleFailure(FailureKind kind, String message) {
super(message);
this.kind = kind;
}
public FailureKind kind() {
return kind;
}
}
}
@@ -1,4 +1,4 @@
package com.baiyue.lastleg.masterdata.vehicle.dto;
package com.baiyue.lastleg.masterdata.vehicle.application.model;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
@@ -11,8 +11,8 @@ import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public final class VehicleDtos {
private VehicleDtos() { }
public final class VehicleModels {
private VehicleModels() { }
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
@Size(max = 128) String keyword, String ownership, String region, String status) {
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.masterdata.vehicle.application.port.out;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface VehicleRepository {
PageSlice page(SearchCriteria criteria);
List<VehicleSnapshot> list(SearchCriteria criteria);
Optional<VehicleSnapshot> findById(long id);
Optional<VehicleSnapshot> lockById(long id);
long create(NewVehicle vehicle);
boolean update(long id, long expectedVersion, VehicleChanges changes);
boolean updateStatus(long id, long expectedVersion, String status, String operator, LocalDateTime updatedAt);
record SearchCriteria(int page, int size, String keyword, String ownership, String region, String status) { }
record PageSlice(List<VehicleSnapshot> records, long page, long size, long total, long pages) { }
record NewVehicle(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm,
String driverOrDispatchCompany, String phone, String region, String ownership, String status,
String remark, String operator, LocalDateTime createdAt) { }
record VehicleChanges(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm,
String driverOrDispatchCompany, String phone, String region, String ownership, String remark,
String operator, LocalDateTime updatedAt) { }
record VehicleSnapshot(long id, String plateNumber, String model, BigDecimal fuelConsumptionPer100Km,
long mileageKm, String driverOrDispatchCompany, String phone, String region,
String ownership, String status, String remark, long recordVersion,
String updatedBy, LocalDateTime updatedAt) { }
final class DuplicatePlateException extends RuntimeException {
public DuplicatePlateException(Throwable cause) {
super(cause);
}
}
}
@@ -1,58 +0,0 @@
package com.baiyue.lastleg.masterdata.vehicle.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos;
import com.baiyue.lastleg.masterdata.vehicle.service.VehicleService;
import com.baiyue.lastleg.security.OverseasPermissions;
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.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/api/platform/v1/last-leg/master-data/vehicles")
@Tag(name = "Vehicle master data / 车辆主数据")
public class PlatformVehicleController {
@Resource private VehicleService service;
@GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')")
@Operation(summary = "Page vehicle master data", description = "Required authority: platform:overseas-master-data:vehicle:list")
public ApiResponse<PageResult<VehicleDtos.Response>> page(@Valid VehicleDtos.Query query) { return ApiResponse.success(service.page(query)); }
@GetMapping("/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')")
@Operation(summary = "Get vehicle master data detail", description = "Required authority: platform:overseas-master-data:vehicle:list; excludes delivery fulfillment history")
public ApiResponse<VehicleDtos.Response> detail(@PathVariable long id) { return ApiResponse.success(service.detail(id)); }
@PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_CREATE + "')")
public ApiResponse<VehicleDtos.Response> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody VehicleDtos.Create request) { return ApiResponse.success(service.create(operator(user), request)); }
@PutMapping("/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EDIT + "')")
public ApiResponse<VehicleDtos.Response> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
@Valid @RequestBody VehicleDtos.Update request) { return ApiResponse.success(service.update(id, operator(user), request)); }
@PatchMapping("/{id}/status") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_STATUS + "')")
public ApiResponse<VehicleDtos.Response> status(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
@Valid @RequestBody VehicleDtos.ChangeStatus request) { return ApiResponse.success(service.changeStatus(id, operator(user), request)); }
@GetMapping(value = "/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EXPORT + "')")
public ResponseEntity<byte[]> export(@Valid VehicleDtos.Query query) {
return ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=vehicles.csv").body(service.exportCsv(query));
}
private String operator(AuthenticatedUser user) {
if (user == null) return "UNKNOWN"; return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -1,143 +0,0 @@
package com.baiyue.lastleg.masterdata.vehicle.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos;
import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle;
import com.baiyue.lastleg.masterdata.vehicle.mapper.VehicleMapper;
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.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Set;
@Service
public class VehicleService {
private static final Set<String> OWNERSHIPS = Set.of("OWNED", "DISPATCHED");
private static final Set<String> REGIONS = Set.of("DOMESTIC", "OVERSEAS");
private static final Set<String> STATUSES = Set.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED");
@Resource private VehicleMapper vehicles;
public PageResult<VehicleDtos.Response> page(VehicleDtos.Query query) {
Page<Vehicle> page = vehicles.selectPage(Page.of(query.page(), query.size()), wrapper(query));
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(),
page.getSize(), page.getTotal(), page.getPages());
}
public VehicleDtos.Response detail(long id) { return response(required(id)); }
@Transactional
public VehicleDtos.Response create(String operator, VehicleDtos.Create request) {
LocalDateTime now = LocalDateTime.now();
Vehicle value = new Vehicle(); value.setId(IdWorker.getId());
value.setPlateNumber(requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT));
value.setModel(requiredText(request.model(), "车型")); value.setFuelConsumptionPer100Km(request.fuelConsumptionPer100Km());
value.setMileageKm(request.mileageKm()); value.setDriverOrDispatchCompany(trim(request.driverOrDispatchCompany()));
value.setPhone(trim(request.phone())); value.setRegion(region(request.region())); value.setOwnership(ownership(request.ownership()));
value.setStatus(status(request.status())); value.setRemark(trim(request.remark())); value.setRecordVersion(0L);
value.setCreatedBy(operator); value.setUpdatedBy(operator); value.setCreatedAt(now); value.setUpdatedAt(now);
try { vehicles.insert(value); } catch (DuplicateKeyException exception) { throw conflict("车牌号已存在"); }
return detail(value.getId());
}
@Transactional
public VehicleDtos.Response update(long id, String operator, VehicleDtos.Update request) {
Vehicle current = locked(id); requireVersion(current, request.recordVersion());
if (request.mileageKm() < current.getMileageKm()) throw badRequest("当前公里码不能小于已有公里码");
int updated;
try {
updated = vehicles.update(null, new LambdaUpdateWrapper<Vehicle>()
.eq(Vehicle::getId, id).eq(Vehicle::getRecordVersion, request.recordVersion())
.set(Vehicle::getPlateNumber, requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT))
.set(Vehicle::getModel, requiredText(request.model(), "车型"))
.set(Vehicle::getFuelConsumptionPer100Km, request.fuelConsumptionPer100Km())
.set(Vehicle::getMileageKm, request.mileageKm())
.set(Vehicle::getDriverOrDispatchCompany, trim(request.driverOrDispatchCompany()))
.set(Vehicle::getPhone, trim(request.phone())).set(Vehicle::getRegion, region(request.region()))
.set(Vehicle::getOwnership, ownership(request.ownership())).set(Vehicle::getRemark, trim(request.remark()))
.set(Vehicle::getUpdatedBy, operator).set(Vehicle::getUpdatedAt, LocalDateTime.now())
.setSql("record_version = record_version + 1"));
} catch (DuplicateKeyException exception) { throw conflict("车牌号已存在"); }
if (updated != 1) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
return detail(id);
}
@Transactional
public VehicleDtos.Response changeStatus(long id, String operator, VehicleDtos.ChangeStatus request) {
Vehicle current = locked(id); requireVersion(current, request.recordVersion());
String status = status(request.status());
if (status.equals(current.getStatus())) return response(current);
int updated = vehicles.update(null, new LambdaUpdateWrapper<Vehicle>()
.eq(Vehicle::getId, id).eq(Vehicle::getRecordVersion, request.recordVersion())
.set(Vehicle::getStatus, status).set(Vehicle::getUpdatedBy, operator)
.set(Vehicle::getUpdatedAt, LocalDateTime.now()).setSql("record_version = record_version + 1"));
if (updated != 1) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
return detail(id);
}
public byte[] exportCsv(VehicleDtos.Query query) {
var rows = vehicles.selectList(wrapper(query)).stream().map(this::response).toList();
StringBuilder csv = new StringBuilder("\uFEFF车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n");
for (var row : rows) csv.append(row.id()).append(',').append(cell(row.plateNumber())).append(',')
.append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',')
.append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',')
.append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',')
.append(row.updatedAt()).append("\r\n");
return csv.toString().getBytes(StandardCharsets.UTF_8);
}
private LambdaQueryWrapper<Vehicle> wrapper(VehicleDtos.Query query) {
String keyword = trim(query.keyword()); String ownership = optional(query.ownership(), OWNERSHIPS, "车辆归属");
String region = optional(query.region(), REGIONS, "车辆区域"); String status = optional(query.status(), STATUSES, "车辆状态");
return new LambdaQueryWrapper<Vehicle>()
.and(StringUtils.hasText(keyword), w -> w.like(Vehicle::getPlateNumber, keyword)
.or().like(Vehicle::getModel, keyword).or().like(Vehicle::getDriverOrDispatchCompany, keyword))
.eq(ownership != null, Vehicle::getOwnership, ownership).eq(region != null, Vehicle::getRegion, region)
.eq(status != null, Vehicle::getStatus, status).orderByDesc(Vehicle::getUpdatedAt).orderByDesc(Vehicle::getId);
}
private Vehicle required(long id) {
Vehicle value = vehicles.selectById(id); if (value == null) throw notFound(); return value;
}
private Vehicle locked(long id) {
Vehicle value = vehicles.lockById(id); if (value == null) throw notFound(); return value;
}
private VehicleDtos.Response response(Vehicle value) {
return new VehicleDtos.Response(String.valueOf(value.getId()), value.getPlateNumber(), value.getModel(),
value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(),
value.getRegion(), value.getOwnership(), value.getStatus(), value.getRemark(), version(value.getRecordVersion()),
value.getUpdatedBy(), value.getUpdatedAt());
}
private void requireVersion(Vehicle value, long expected) {
if (version(value.getRecordVersion()) != expected) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
}
private static String ownership(String value) { return required(value, OWNERSHIPS, "车辆归属"); }
private static String region(String value) { return required(value, REGIONS, "车辆区域"); }
private static String status(String value) { return required(value, STATUSES, "车辆状态"); }
private static String optional(String value, Set<String> allowed, String label) {
return StringUtils.hasText(value) ? required(value, allowed, label) : null;
}
private static String required(String value, Set<String> allowed, String label) {
if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空");
String normalized = value.trim().toUpperCase(Locale.ROOT);
if (!allowed.contains(normalized)) throw badRequest(label + "不合法"); return normalized;
}
private static String requiredText(String value, String label) {
if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空"); return value.trim();
}
private static long version(Long value) { return value == null ? 0L : value; }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "车辆档案不存在"); }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -0,0 +1,58 @@
package com.baiyue.lastleg.palletops.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.palletops.dto.PalletOperationRequests;
import com.baiyue.lastleg.palletops.dto.PalletOperationResponses;
import com.baiyue.lastleg.palletops.service.PdaPalletOperationService;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
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.RestController;
@RestController
@RequestMapping("/api/pda/v1/last-leg/pallet-operations")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')")
public class PdaPalletOperationController {
@Resource private PdaPalletOperationService service;
@PostMapping("/pallets/resolve")
public ApiResponse<PalletOperationResponses.PalletContent> resolve(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.Scan request) {
return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/assemble")
public ApiResponse<PalletOperationResponses.Operation> assemble(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.Assemble request) {
return ApiResponse.success(service.assemble(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/disassemble")
public ApiResponse<PalletOperationResponses.Operation> disassemble(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.Disassemble request) {
return ApiResponse.success(service.disassemble(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/swap")
public ApiResponse<PalletOperationResponses.Operation> swap(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.Swap request) {
return ApiResponse.success(service.swap(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/unbind")
public ApiResponse<PalletOperationResponses.Operation> unbind(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.Unbind request) {
return ApiResponse.success(service.unbind(PdaWarehouseIdentity.require(user), request));
}
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.palletops.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.util.List;
public final class PalletOperationRequests {
private PalletOperationRequests() { }
public record Scan(@NotBlank @Size(max = 128) String code) { }
public record PalletExpectation(@NotBlank @Size(max = 128) String palletCode,
@PositiveOrZero int expectedVersion) { }
public record MoveLine(@Positive long positionId, @Positive long packages,
@PositiveOrZero long expectedPositionVersion) { }
public record TargetMove(@Valid PalletExpectation target,
@NotEmpty @Size(max = 200) List<@Valid MoveLine> lines) { }
public record Assemble(@NotBlank @Size(max = 128) String idempotencyKey,
@Valid PalletExpectation target,
@NotEmpty @Size(max = 20) List<@Valid PalletExpectation> sources,
@NotEmpty @Size(max = 200) List<@Valid MoveLine> lines,
@Size(max = 500) String reason) { }
public record Disassemble(@NotBlank @Size(max = 128) String idempotencyKey,
@Valid PalletExpectation source,
@NotEmpty @Size(max = 20) List<@Valid TargetMove> targets,
@Size(max = 500) String reason) { }
public record Swap(@NotBlank @Size(max = 128) String idempotencyKey,
@Valid PalletExpectation source,
@Valid PalletExpectation target,
@Size(max = 500) String reason) { }
public record Unbind(@NotBlank @Size(max = 128) String idempotencyKey,
@Valid PalletExpectation source,
@Size(max = 500) String reason) { }
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.palletops.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class PalletOperationResponses {
private PalletOperationResponses() { }
public record InventoryLine(String customerId, String customerName, long balanceId, long positionId,
String inboundCode, String skuCode, String productName,
long availablePackages, long holdPackages, long allocatedPackages,
long pickedPackages, long receivingPackages, long positionVersion) { }
public record PalletContent(long palletId, String palletCode, String status, int palletVersion,
long locationId, String locationCode, String destinationWarehouseCode,
long totalPackages, List<InventoryLine> inventory) { }
public record Operation(long operationId, String operationType, String idempotencyKey,
String status, boolean replayed, LocalDateTime completedAt,
List<PalletContent> pallets) {
public Operation asReplay() {
return new Operation(operationId, operationType, idempotencyKey, status, true, completedAt, pallets);
}
}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.palletops.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_pda_pallet_operation")
public class PdaPalletOperation {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long overseasWarehouseId;
private String operatorKey;
private String idempotencyKey;
private String operationType;
private String requestHash;
private String status;
private String resultJson;
private String remoteTransitionsJson;
private String lastError;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,82 @@
package com.baiyue.lastleg.palletops.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.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.util.LinkedHashMap;
import java.util.Map;
@Component
public class HttpPalletPhysicalGateway implements PalletPhysicalGateway {
@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;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client;
@PostConstruct void initialize() {
client = restClientFactory.build(restClientBuilder, baseUrl,
restClientProperties.getPalletPhysical());
}
@Override
public PalletReference resolve(String palletCode) {
ResolveEnvelope response = client.get().uri("/internal/v1/warehouse-pallets/resolve/{code}", palletCode)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalArgumentException("Warehouse rejected pallet scan: HTTP " + result.getStatusCode());
}).body(ResolveEnvelope.class);
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no pallet");
return response.data();
}
@Override
public LocationReference resolveLocation(long warehouseId, long locationId) {
LocationEnvelope 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()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalArgumentException("Warehouse rejected location lookup: HTTP " + result.getStatusCode());
}).body(LocationEnvelope.class);
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no location");
return response.data();
}
@Override
public Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId,
String operationKey, String reason) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("warehouseId", warehouseId); body.put("expectedVersion", pallet.version());
body.put("targetStatus", targetStatus); body.put("targetLocationId", targetLocationId);
body.put("operationKey", operationKey); if (reason != null) body.put("reason", reason);
TransitionEnvelope response = client.post().uri("/internal/v1/pda-pallet-transitions/{id}", pallet.palletId())
.header("X-Internal-Service-Token", internalToken).body(body).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalStateException("Warehouse rejected pallet transition: HTTP " + result.getStatusCode());
}).body(TransitionEnvelope.class);
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no transition");
return response.data();
}
@Override
public void compensate(long warehouseId, Transition transition) {
client.post().uri("/internal/v1/pda-pallet-transitions/{id}/{key}/compensate",
transition.palletId(), transition.operationKey())
.header("X-Internal-Service-Token", internalToken).body(Map.of("warehouseId", warehouseId))
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalStateException("Warehouse rejected pallet compensation: HTTP " + result.getStatusCode());
}).toBodilessEntity();
}
private record ResolveEnvelope(PalletReference data) { }
private record LocationEnvelope(LocationReference data) { }
private record TransitionEnvelope(Transition data) { }
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.palletops.gateway;
public interface PalletPhysicalGateway {
PalletReference resolve(String palletCode);
LocationReference resolveLocation(long warehouseId, long locationId);
Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId,
String operationKey, String reason);
void compensate(long warehouseId, Transition transition);
record PalletReference(long palletId, String palletCode, long warehouseId, long locationId,
String status, String fbaWarehouseCode, int version) { }
record LocationReference(long locationId, String locationCode, String locationType, String status) { }
record Transition(String operationKey, long palletId, String palletCode, long warehouseId,
String beforeStatus, String afterStatus, long beforeLocationId, long afterLocationId,
int beforeVersion, int afterVersion, String operationStatus, boolean replayed) { }
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.palletops.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
import com.baiyue.lastleg.palletops.model.PalletInventoryRow;
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 PdaPalletOperationMapper extends BaseMapper<PdaPalletOperation> {
String CONTENT_SELECT = "SELECT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," +
"b.inbound_code,b.sku_code,b.product_name_zh,b.product_name_en,b.destination_warehouse_code," +
"p.pallet_id,p.pallet_no,p.batch_no,p.warehouse_location_id,p.warehouse_location_code," +
"p.available_packages,p.hold_packages,p.allocated_packages,p.picked_packages,p.receiving_packages,p.version " +
"FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=p.customer_id ";
@Select("SELECT * FROM ll_pda_pallet_operation WHERE operator_key=#{operatorKey} " +
"AND idempotency_key=#{idempotencyKey} LIMIT 1")
PdaPalletOperation findOperation(@Param("operatorKey") String operatorKey,
@Param("idempotencyKey") String idempotencyKey);
@Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " +
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)&gt;0 " +
"ORDER BY b.customer_id,b.inbound_code,p.id")
List<PalletInventoryRow> content(@Param("warehouseId") String warehouseId,
@Param("palletId") long palletId);
@Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.id=#{positionId} " +
"LIMIT 1 FOR UPDATE")
PalletInventoryRow lockPosition(@Param("warehouseId") String warehouseId,
@Param("positionId") long positionId);
@Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId} " +
"AND pallet_id=#{palletId} AND batch_no=#{batchNo} LIMIT 1 FOR UPDATE")
InventoryPosition lockTarget(@Param("customerId") String customerId, @Param("balanceId") long balanceId,
@Param("palletId") long palletId, @Param("batchNo") String batchNo);
@Select("SELECT COALESCE(SUM(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages),0) " +
"FROM ll_inventory_position WHERE pallet_id=#{palletId} AND overseas_warehouse_id=#{warehouseId}")
long palletPackages(@Param("warehouseId") String warehouseId, @Param("palletId") long palletId);
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.palletops.model;
import lombok.Data;
@Data
public class PalletInventoryRow {
private String customerId;
private String customerName;
private Long balanceId;
private Long positionId;
private String inboundCode;
private String skuCode;
private String productNameZh;
private String productNameEn;
private String destinationWarehouseCode;
private Long palletId;
private String palletNo;
private String batchNo;
private Long warehouseLocationId;
private String warehouseLocationCode;
private Long availablePackages;
private Long holdPackages;
private Long allocatedPackages;
private Long pickedPackages;
private Long receivingPackages;
private Long version;
}
@@ -0,0 +1,506 @@
package com.baiyue.lastleg.palletops.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.palletops.dto.PalletOperationRequests;
import com.baiyue.lastleg.palletops.dto.PalletOperationResponses;
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
import com.baiyue.lastleg.palletops.model.PalletInventoryRow;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Service
public class PdaPalletOperationService {
@Resource private PdaPalletOperationMapper mapper;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@Resource private InventoryLedgerMapper ledgers;
@Resource private PalletPhysicalGateway physical;
@Resource private PdaWarehouseService history;
@Resource private ObjectMapper objectMapper;
private TransactionTemplate transaction;
private TransactionTemplate newTransaction;
@Resource
void configureTransactions(PlatformTransactionManager manager) {
transaction = new TransactionTemplate(manager);
DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
newTransaction = new TransactionTemplate(manager, definition);
}
public PalletOperationResponses.PalletContent resolve(PdaWarehouseIdentity identity,
PalletOperationRequests.Scan request) {
PalletPhysicalGateway.PalletReference pallet = requirePallet(identity.overseasWarehouseId(), request.code());
return content(identity.overseasWarehouseId(), pallet);
}
public PalletOperationResponses.Operation assemble(PdaWarehouseIdentity identity,
PalletOperationRequests.Assemble request) {
Map<String, PalletPhysicalGateway.PalletReference> pallets = resolvePallets(identity.overseasWarehouseId(),
request.target(), request.sources());
PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode()));
Map<String, PalletPhysicalGateway.PalletReference> sources = new LinkedHashMap<>();
request.sources().forEach(value -> sources.put(code(value.palletCode()), pallets.get(code(value.palletCode()))));
List<PlannedMove> moves = plannedLines(identity.overseasWarehouseId(), request.lines(), sources,
target, null);
requireCommonDestination(identity.overseasWarehouseId(), moves, List.of(target));
return execute(identity, "PALLET_ASSEMBLE", request.idempotencyKey(), request, request.reason(),
plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false));
}
public PalletOperationResponses.Operation disassemble(PdaWarehouseIdentity identity,
PalletOperationRequests.Disassemble request) {
LinkedHashMap<String, PalletOperationRequests.PalletExpectation> expectations = new LinkedHashMap<>();
expectations.put(code(request.source().palletCode()), request.source());
request.targets().forEach(value -> expectations.put(code(value.target().palletCode()), value.target()));
Map<String, PalletPhysicalGateway.PalletReference> pallets = resolvePallets(identity.overseasWarehouseId(),
expectations.values());
PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode()));
List<PlannedMove> moves = new ArrayList<>();
for (PalletOperationRequests.TargetMove targetMove : request.targets()) {
PalletPhysicalGateway.PalletReference target = pallets.get(code(targetMove.target().palletCode()));
moves.addAll(plannedLines(identity.overseasWarehouseId(), targetMove.lines(), Map.of(
code(request.source().palletCode()), source), target, source));
}
requireUniqueLines(moves);
List<PalletPhysicalGateway.PalletReference> targets = request.targets().stream()
.map(value -> pallets.get(code(value.target().palletCode()))).toList();
requireCommonDestination(identity.overseasWarehouseId(), moves, targets);
return execute(identity, "PALLET_DISASSEMBLE", request.idempotencyKey(), request, request.reason(),
plan(identity.overseasWarehouseId(), pallets, moves,
targets.stream().map(PalletPhysicalGateway.PalletReference::palletId).collect(java.util.stream.Collectors.toSet()),
false));
}
public PalletOperationResponses.Operation swap(PdaWarehouseIdentity identity,
PalletOperationRequests.Swap request) {
Map<String, PalletPhysicalGateway.PalletReference> pallets = resolvePallets(identity.overseasWarehouseId(),
request.source(), List.of(request.target()));
PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode()));
PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode()));
if (!mapper.content(String.valueOf(identity.overseasWarehouseId()), target.palletId()).isEmpty()) {
throw conflict("换托目标托盘必须为空");
}
List<PalletInventoryRow> rows = nonEmptyContent(identity.overseasWarehouseId(), source.palletId());
List<PlannedMove> moves = rows.stream().map(row -> {
requireMovable(row, value(row.getAvailablePackages()));
return new PlannedMove(row.getPositionId(), value(row.getAvailablePackages()), value(row.getVersion()),
source.palletId(), source.palletCode(), target.palletId(), target.palletCode(), target.locationId(),
locationCode(identity.overseasWarehouseId(), target.locationId()));
}).toList();
return execute(identity, "PALLET_SWAP", request.idempotencyKey(), request, request.reason(),
plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false));
}
public PalletOperationResponses.Operation unbind(PdaWarehouseIdentity identity,
PalletOperationRequests.Unbind request) {
Map<String, PalletPhysicalGateway.PalletReference> pallets = resolvePallets(identity.overseasWarehouseId(),
List.of(request.source()));
PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode()));
List<PalletInventoryRow> rows = nonEmptyContent(identity.overseasWarehouseId(), source.palletId());
List<PlannedMove> moves = rows.stream().map(row -> {
requireMovable(row, value(row.getAvailablePackages()));
return new PlannedMove(row.getPositionId(), value(row.getAvailablePackages()), value(row.getVersion()),
source.palletId(), source.palletCode(), 0L, "UNBOUND", source.locationId(),
locationCode(identity.overseasWarehouseId(), source.locationId()));
}).toList();
return execute(identity, "PALLET_UNBIND", request.idempotencyKey(), request, request.reason(),
plan(identity.overseasWarehouseId(), pallets, moves, Set.of(), true));
}
private PalletOperationResponses.Operation execute(PdaWarehouseIdentity identity, String type, String key,
Object request, String reason, Plan plan) {
String normalizedKey = required(key, "幂等键不能为空");
String requestHash = hash(request);
PdaPalletOperation operation = begin(identity, type, normalizedKey, requestHash);
if ("COMPLETED".equals(operation.getStatus())) return read(operation).asReplay();
List<PalletPhysicalGateway.Transition> transitions = new ArrayList<>();
try {
for (PalletChange change : plan.changes()) {
transitions.add(physical.apply(identity.overseasWarehouseId(), change.pallet(), change.targetStatus(),
change.targetLocationId(), transitionKey(identity.operatorKey(), normalizedKey,
change.pallet().palletId()), reason));
}
saveTransitions(operation.getId(), transitions);
PalletOperationResponses.Operation result = transaction.execute(status ->
mutate(identity, operation, plan, normalizedKey, requestHash, reason));
if (result == null) throw new IllegalStateException("托盘作业事务没有返回结果");
return result;
} catch (RuntimeException failure) {
boolean compensated = compensate(identity.overseasWarehouseId(), transitions);
fail(operation.getId(), failure, compensated);
throw failure;
}
}
private PalletOperationResponses.Operation mutate(PdaWarehouseIdentity identity, PdaPalletOperation operation,
Plan plan, String key, String requestHash, String reason) {
Set<Long> seen = new HashSet<>();
LinkedHashSet<String> customers = new LinkedHashSet<>();
for (PlannedMove move : plan.moves()) {
if (!seen.add(move.positionId())) throw conflict("同一库存行不能在一次托盘作业中重复移动");
PalletInventoryRow locked = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()),
move.positionId());
if (locked == null || !Objects.equals(locked.getPalletId(), move.sourcePalletId())) {
throw conflict("库存所在托盘已变化,请重新扫码");
}
if (value(locked.getVersion()) != move.expectedVersion()) throw conflict("库存版本已变化,请重新扫码");
requireMovable(locked, move.packages());
customers.add(locked.getCustomerId());
moveInventory(identity, operation, locked, move, key, reason);
}
for (PalletChange change : plan.changes()) {
long actual = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), change.pallet().palletId());
boolean shouldBeEmpty = "IDLE".equals(change.targetStatus());
if (shouldBeEmpty != (actual == 0)) {
throw conflict("库存与实物托盘状态不一致,作业已回滚");
}
}
List<PalletOperationResponses.PalletContent> contents = plan.pallets().values().stream()
.sorted(Comparator.comparingLong(PalletPhysicalGateway.PalletReference::palletId))
.map(pallet -> content(identity.overseasWarehouseId(), physical.resolve(pallet.palletCode())))
.toList();
PalletOperationResponses.Operation result = new PalletOperationResponses.Operation(operation.getId(),
operation.getOperationType(), key, "COMPLETED", false, LocalDateTime.now(), contents);
operation.setStatus("COMPLETED"); operation.setResultJson(write(result)); operation.setLastError(null);
operation.setUpdatedAt(result.completedAt()); mapper.updateById(operation);
history.record(identity.overseasWarehouseId(), customers.size() == 1 ? customers.iterator().next() : null,
identity.operatorKey(), operation.getOperationType(), businessKey(plan), "COMPLETED", key, requestHash,
"托盘作业完成,移动库存行 " + plan.moves().size() + " 条");
return result;
}
private void moveInventory(PdaWarehouseIdentity identity, PdaPalletOperation operation,
PalletInventoryRow sourceRow, PlannedMove move, String key, String reason) {
InventoryPosition source = positions.selectById(sourceRow.getPositionId());
if (source == null) throw conflict("库存行不存在");
long sourceBefore = value(source.getAvailablePackages());
source.setAvailablePackages(sourceBefore - move.packages());
source.setStatus(positionPackages(source) == 0 ? "EMPTY" : "OCCUPIED");
source.setVersion(value(source.getVersion()) + 1); source.setUpdatedAt(LocalDateTime.now());
positions.updateById(source);
InventoryPosition target = mapper.lockTarget(source.getCustomerId(), source.getBalanceId(),
move.targetPalletId(), source.getBatchNo());
if (target == null) {
target = new InventoryPosition(); target.setId(IdWorker.getId());
target.setCustomerId(source.getCustomerId()); target.setBalanceId(source.getBalanceId());
target.setOverseasWarehouseId(source.getOverseasWarehouseId());
target.setDestinationWarehouseCode(source.getDestinationWarehouseCode());
target.setPalletId(move.targetPalletId()); target.setPalletNo(move.targetPalletCode());
target.setBatchNo(source.getBatchNo()); target.setWarehouseLocationId(move.targetLocationId());
target.setWarehouseLocationCode(move.targetLocationCode());
target.setAvailablePackages(0L); target.setHoldPackages(0L); target.setAllocatedPackages(0L);
target.setPickedPackages(0L); target.setReceivingPackages(0L); target.setStatus("OCCUPIED");
target.setVersion(0L); target.setCreatedAt(LocalDateTime.now()); target.setUpdatedAt(target.getCreatedAt());
try { positions.insert(target); }
catch (DuplicateKeyException duplicate) {
target = mapper.lockTarget(source.getCustomerId(), source.getBalanceId(), move.targetPalletId(), source.getBatchNo());
if (target == null) throw duplicate;
}
}
long targetBefore = value(target.getAvailablePackages());
target.setAvailablePackages(targetBefore + move.packages()); target.setStatus("OCCUPIED");
target.setWarehouseLocationId(move.targetLocationId());
target.setWarehouseLocationCode(move.targetLocationCode());
target.setVersion(value(target.getVersion()) + 1); target.setUpdatedAt(LocalDateTime.now());
positions.updateById(target);
InventoryBalance balance = balances.selectById(source.getBalanceId());
if (balance == null) throw new IllegalStateException("库存余额不存在");
ledger(identity, operation, balance, source, key + ":" + source.getId() + ":OUT",
sourceBefore, -move.packages(), sourceBefore - move.packages(), move.sourcePalletCode(), move.targetPalletCode(), reason);
ledger(identity, operation, balance, target, key + ":" + source.getId() + ":IN",
targetBefore, move.packages(), targetBefore + move.packages(), move.sourcePalletCode(), move.targetPalletCode(), reason);
}
private void ledger(PdaWarehouseIdentity identity, PdaPalletOperation operation, InventoryBalance balance,
InventoryPosition position, String idempotencyKey, long before, long delta, long after,
String sourcePallet, String targetPallet, String reason) {
InventoryLedger ledger = new InventoryLedger(); ledger.setId(IdWorker.getId());
ledger.setCustomerId(balance.getCustomerId()); ledger.setBalanceId(balance.getId());
ledger.setPositionId(position.getId()); ledger.setOverseasWarehouseId(balance.getOverseasWarehouseId());
ledger.setDestinationWarehouseCode(balance.getDestinationWarehouseCode());
ledger.setInboundOrderId(balance.getInboundOrderId()); ledger.setPalletId(position.getPalletId());
ledger.setPalletNo(position.getPalletNo()); ledger.setBatchNo(position.getBatchNo());
ledger.setSkuCode(balance.getSkuCode()); ledger.setWarehouseLocationCode(position.getWarehouseLocationCode());
ledger.setBusinessType(operation.getOperationType()); ledger.setBusinessOrderId(operation.getId());
ledger.setBusinessOrderNo(String.valueOf(operation.getId())); ledger.setInboundCode(balance.getInboundCode());
ledger.setInboundOrderNo(balance.getInboundOrderNo()); ledger.setIdempotencyKey(idempotencyKey);
ledger.setStatusFrom("PALLET:" + sourcePallet); ledger.setStatusTo("PALLET:" + targetPallet);
ledger.setBeforePackages(before); ledger.setDeltaPackages(delta); ledger.setAfterPackages(after);
ledger.setOperatorId(identity.operatorKey()); ledger.setRemark(trim(reason));
ledger.setOccurredAt(LocalDateTime.now()); ledger.setCreatedAt(ledger.getOccurredAt()); ledgers.insert(ledger);
}
private Plan plan(long warehouseId, Map<String, PalletPhysicalGateway.PalletReference> pallets,
List<PlannedMove> moves, Set<Long> targets, boolean unbind) {
Map<Long, Long> movedBySource = new HashMap<>();
moves.forEach(move -> movedBySource.merge(move.sourcePalletId(), move.packages(), Long::sum));
List<PalletChange> changes = new ArrayList<>();
for (PalletPhysicalGateway.PalletReference pallet : pallets.values()) {
long total = mapper.palletPackages(String.valueOf(warehouseId), pallet.palletId());
long moved = movedBySource.getOrDefault(pallet.palletId(), 0L);
String targetStatus = targets.contains(pallet.palletId()) ? "OCCUPIED"
: moved > 0 && moved == total ? "IDLE" : pallet.status();
if ("SCRAPPED".equals(targetStatus)) throw conflict("已报废托盘不能执行作业");
changes.add(new PalletChange(pallet, targetStatus, pallet.locationId()));
}
if (unbind && changes.stream().noneMatch(change -> "IDLE".equals(change.targetStatus()))) {
throw conflict("解绑必须清空来源托盘");
}
changes.sort(Comparator.comparingLong(change -> change.pallet().palletId()));
return new Plan(pallets, moves, changes);
}
private List<PlannedMove> plannedLines(long warehouseId, List<PalletOperationRequests.MoveLine> lines,
Map<String, PalletPhysicalGateway.PalletReference> sources,
PalletPhysicalGateway.PalletReference target,
PalletPhysicalGateway.PalletReference requiredSource) {
Map<Long, PalletInventoryRow> available = new HashMap<>();
for (PalletPhysicalGateway.PalletReference source : sources.values()) {
for (PalletInventoryRow row : mapper.content(String.valueOf(warehouseId), source.palletId())) {
available.put(row.getPositionId(), row);
}
}
List<PlannedMove> result = new ArrayList<>();
for (PalletOperationRequests.MoveLine line : lines) {
PalletInventoryRow row = available.get(line.positionId());
if (row == null || requiredSource != null && !Objects.equals(row.getPalletId(), requiredSource.palletId())) {
throw conflict("库存行不属于已扫描的来源托盘");
}
if (value(row.getVersion()) != line.expectedPositionVersion()) throw conflict("库存版本已变化,请重新扫码");
requireMovable(row, line.packages());
result.add(new PlannedMove(row.getPositionId(), line.packages(), line.expectedPositionVersion(),
row.getPalletId(), row.getPalletNo(), target.palletId(), target.palletCode(), target.locationId(),
locationCode(warehouseId, target.locationId())));
}
requireUniqueLines(result);
return result;
}
private void requireCommonDestination(long warehouseId, List<PlannedMove> moves,
List<PalletPhysicalGateway.PalletReference> targets) {
LinkedHashSet<String> destinations = new LinkedHashSet<>();
for (PlannedMove move : moves) {
PalletInventoryRow row = mapper.content(String.valueOf(warehouseId), move.sourcePalletId()).stream()
.filter(value -> Objects.equals(value.getPositionId(), move.positionId())).findFirst()
.orElseThrow(() -> conflict("库存行不存在"));
destinations.add(text(row.getDestinationWarehouseCode()));
}
for (PalletPhysicalGateway.PalletReference target : targets) {
mapper.content(String.valueOf(warehouseId), target.palletId()).stream()
.map(PalletInventoryRow::getDestinationWarehouseCode).map(PdaPalletOperationService::text)
.forEach(destinations::add);
}
if (destinations.size() > 1) throw conflict("不同目的仓货物不能组到同一托盘");
}
private Map<String, PalletPhysicalGateway.PalletReference> resolvePallets(long warehouseId,
PalletOperationRequests.PalletExpectation first, List<PalletOperationRequests.PalletExpectation> rest) {
List<PalletOperationRequests.PalletExpectation> values = new ArrayList<>(); values.add(first); values.addAll(rest);
return resolvePallets(warehouseId, values);
}
private Map<String, PalletPhysicalGateway.PalletReference> resolvePallets(long warehouseId,
java.util.Collection<PalletOperationRequests.PalletExpectation> values) {
LinkedHashMap<String, PalletPhysicalGateway.PalletReference> result = new LinkedHashMap<>();
for (PalletOperationRequests.PalletExpectation expected : values) {
String code = code(expected.palletCode());
if (result.containsKey(code)) throw badRequest("一次作业不能重复提交同一托盘");
PalletPhysicalGateway.PalletReference pallet = requirePallet(warehouseId, code);
if (pallet.version() != expected.expectedVersion()) throw conflict("托盘版本已变化,请重新扫码");
result.put(code, pallet);
}
return result;
}
private PalletPhysicalGateway.PalletReference requirePallet(long warehouseId, String code) {
PalletPhysicalGateway.PalletReference pallet = physical.resolve(code(code));
if (pallet.warehouseId() != warehouseId) throw conflict("托盘不属于 Token 冻结的海外仓");
if ("SCRAPPED".equals(pallet.status())) throw conflict("已报废托盘不能执行作业");
return pallet;
}
private List<PalletInventoryRow> nonEmptyContent(long warehouseId, long palletId) {
List<PalletInventoryRow> values = mapper.content(String.valueOf(warehouseId), palletId);
if (values.isEmpty()) throw conflict("来源托盘没有可操作库存");
return values;
}
private PalletOperationResponses.PalletContent content(long warehouseId,
PalletPhysicalGateway.PalletReference pallet) {
List<PalletInventoryRow> rows = mapper.content(String.valueOf(warehouseId), pallet.palletId());
LinkedHashSet<String> destinations = new LinkedHashSet<>();
List<PalletOperationResponses.InventoryLine> inventory = rows.stream().map(row -> {
destinations.add(text(row.getDestinationWarehouseCode()));
return new PalletOperationResponses.InventoryLine(row.getCustomerId(), row.getCustomerName(),
value(row.getBalanceId()), value(row.getPositionId()), row.getInboundCode(), row.getSkuCode(),
first(row.getProductNameZh(), row.getProductNameEn()), value(row.getAvailablePackages()),
value(row.getHoldPackages()), value(row.getAllocatedPackages()), value(row.getPickedPackages()),
value(row.getReceivingPackages()), value(row.getVersion()));
}).toList();
long total = rows.stream().mapToLong(PdaPalletOperationService::rowPackages).sum();
String locationCode = locationCode(warehouseId, pallet.locationId());
return new PalletOperationResponses.PalletContent(pallet.palletId(), pallet.palletCode(), pallet.status(),
pallet.version(), pallet.locationId(), locationCode, destinations.size() == 1 ? destinations.iterator().next() : null,
total, inventory);
}
private PdaPalletOperation begin(PdaWarehouseIdentity identity, String type, String key, String requestHash) {
PdaPalletOperation existing = mapper.findOperation(identity.operatorKey(), key);
if (existing != null) return validateExisting(existing, type, requestHash);
try {
return newTransaction.execute(status -> {
PdaPalletOperation operation = new PdaPalletOperation(); operation.setId(IdWorker.getId());
operation.setOverseasWarehouseId(identity.overseasWarehouseId());
operation.setOperatorKey(identity.operatorKey()); operation.setIdempotencyKey(key);
operation.setOperationType(type); operation.setRequestHash(requestHash); operation.setStatus("PROCESSING");
operation.setCreatedAt(LocalDateTime.now()); operation.setUpdatedAt(operation.getCreatedAt());
mapper.insert(operation); return operation;
});
} catch (DuplicateKeyException duplicate) {
PdaPalletOperation winner = mapper.findOperation(identity.operatorKey(), key);
if (winner != null) return validateExisting(winner, type, requestHash);
throw duplicate;
}
}
private PdaPalletOperation validateExisting(PdaPalletOperation value, String type, String requestHash) {
if (!type.equals(value.getOperationType()) || !requestHash.equals(value.getRequestHash())) {
throw conflict("幂等键已被不同托盘作业使用");
}
if (!"COMPLETED".equals(value.getStatus())) {
throw conflict("同一托盘作业正在处理或需要人工恢复");
}
return value;
}
private void saveTransitions(long operationId, List<PalletPhysicalGateway.Transition> transitions) {
newTransaction.executeWithoutResult(status -> {
PdaPalletOperation operation = mapper.selectById(operationId);
operation.setRemoteTransitionsJson(write(transitions)); operation.setUpdatedAt(LocalDateTime.now());
mapper.updateById(operation);
});
}
private void fail(long operationId, RuntimeException failure, boolean compensated) {
newTransaction.executeWithoutResult(status -> {
PdaPalletOperation operation = mapper.selectById(operationId);
if (operation == null || "COMPLETED".equals(operation.getStatus())) return;
operation.setStatus(compensated ? "FAILED_COMPENSATED" : "COMPENSATION_REQUIRED");
operation.setLastError(limit(failure.getMessage(), 2000)); operation.setUpdatedAt(LocalDateTime.now());
mapper.updateById(operation);
});
}
private boolean compensate(long warehouseId, List<PalletPhysicalGateway.Transition> transitions) {
boolean success = true;
for (int i = transitions.size() - 1; i >= 0; i--) {
try { physical.compensate(warehouseId, transitions.get(i)); }
catch (RuntimeException ignored) { success = false; }
}
return success;
}
private PalletOperationResponses.Operation read(PdaPalletOperation operation) {
try { return objectMapper.readValue(operation.getResultJson(), PalletOperationResponses.Operation.class); }
catch (JsonProcessingException exception) { throw new IllegalStateException("无法读取托盘作业幂等结果", exception); }
}
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException exception) { throw new IllegalStateException("无法序列化托盘作业", exception); }
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
}
private static String transitionKey(String operator, String key, long palletId) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest((operator + "|" + key + "|" + palletId).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
}
private String locationCode(long warehouseId, long locationId) {
PalletPhysicalGateway.LocationReference location = physical.resolveLocation(warehouseId, locationId);
if (location == null || location.locationId() != locationId || "INACTIVE".equals(location.status())) {
throw conflict("托盘库位不存在或已停用");
}
return location.locationCode();
}
private static void requireMovable(PalletInventoryRow row, long packages) {
if (packages <= 0 || value(row.getAvailablePackages()) < packages) throw conflict("可用库存箱数不足");
if (value(row.getHoldPackages()) > 0 || value(row.getAllocatedPackages()) > 0
|| value(row.getPickedPackages()) > 0 || value(row.getReceivingPackages()) > 0) {
throw conflict("冻结、已分配、已拣或收货中的库存不能执行托盘作业");
}
}
private static void requireUniqueLines(List<PlannedMove> moves) {
Set<Long> ids = new HashSet<>();
if (moves.stream().anyMatch(move -> !ids.add(move.positionId()))) throw badRequest("库存行不能重复提交");
}
private static long rowPackages(PalletInventoryRow row) {
return value(row.getAvailablePackages()) + value(row.getHoldPackages()) + value(row.getAllocatedPackages())
+ value(row.getPickedPackages()) + value(row.getReceivingPackages());
}
private static long positionPackages(InventoryPosition value) {
return value(value.getAvailablePackages()) + value(value.getHoldPackages()) + value(value.getAllocatedPackages())
+ value(value.getPickedPackages()) + value(value.getReceivingPackages());
}
private static String businessKey(Plan plan) {
return plan.pallets().values().stream().map(PalletPhysicalGateway.PalletReference::palletCode)
.sorted().collect(java.util.stream.Collectors.joining("->"));
}
private static String required(String value, String message) { String result = trim(value); if (result == null) throw badRequest(message); return result; }
private static String code(String value) { return required(value, "托盘码不能为空").toUpperCase(); }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String text(String value) { return value == null ? "" : value.trim(); }
private static String first(String first, String second) { String value = trim(first); return value == null ? trim(second) : value; }
private static String limit(String value, int size) { if (value == null) return null; return value.length() <= size ? value : value.substring(0, size); }
private static long value(Long value) { return value == null ? 0L : value; }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private record PlannedMove(long positionId, long packages, long expectedVersion,
long sourcePalletId, String sourcePalletCode,
long targetPalletId, String targetPalletCode, long targetLocationId,
String targetLocationCode) { }
private record PalletChange(PalletPhysicalGateway.PalletReference pallet, String targetStatus,
long targetLocationId) { }
private record Plan(Map<String, PalletPhysicalGateway.PalletReference> pallets,
List<PlannedMove> moves, List<PalletChange> changes) { }
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.pda;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
/** Registers mapper packages used by the overseas PDA warehouse operation workflows. */
@Configuration
@MapperScan(basePackages = {
"com.baiyue.lastleg.palletops.mapper",
"com.baiyue.lastleg.pda.move.mapper",
"com.baiyue.lastleg.pda.stocktake.mapper",
"com.baiyue.lastleg.pda.warehouse.mapper"
})
public class PdaOperationsMapperConfiguration {
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.pda.move.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveResponse;
import com.baiyue.lastleg.pda.move.service.PdaInventoryMoveService;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
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.RestController;
@RestController
@RequestMapping("/api/pda/v1/last-leg/inventory-moves")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')")
public class PdaInventoryMoveController {
@Resource private PdaInventoryMoveService service;
@PostMapping
public ApiResponse<PdaInventoryMoveResponse> move(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaInventoryMoveRequest request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.move(identity.overseasWarehouseId(), identity.operatorKey(), request));
}
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.pda.move.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public record PdaInventoryMoveRequest(
@NotBlank @Size(max = 128) String sourcePalletCode,
@NotBlank @Size(max = 128) String targetLocationCode,
@PositiveOrZero int expectedPalletVersion,
@NotBlank @Size(max = 128) String idempotencyKey,
@NotBlank @Size(max = 500) String reason
) { }
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.pda.move.dto;
import java.time.LocalDateTime;
public record PdaInventoryMoveResponse(
String moveNo,
long palletId,
String palletCode,
long sourceLocationId,
String sourceLocationCode,
long targetLocationId,
String targetLocationCode,
int customerCount,
int positionCount,
long packageCount,
int palletVersion,
String status,
LocalDateTime completedAt,
boolean replayed
) {
public PdaInventoryMoveResponse asReplay() {
return replayed ? this : new PdaInventoryMoveResponse(moveNo, palletId, palletCode, sourceLocationId,
sourceLocationCode, targetLocationId, targetLocationCode, customerCount, positionCount,
packageCount, palletVersion, status, completedAt, true);
}
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.pda.move.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_pda_inventory_move")
public class PdaInventoryMove {
@TableId(type = IdType.INPUT) private Long id;
private String moveNo;
private Long overseasWarehouseId;
private Long palletId;
private String palletCode;
private Long sourceLocationId;
private String sourceLocationCode;
private Long targetLocationId;
private String targetLocationCode;
private Integer customerCount;
private Integer positionCount;
private Long packageCount;
private Integer palletVersionBefore;
private Integer palletVersionAfter;
private String operatorKey;
private String reason;
private String idempotencyKey;
private String requestHash;
private String status;
private String resultJson;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.pda.move.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove;
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 PdaInventoryMoveMapper extends BaseMapper<PdaInventoryMove> {
@Select("SELECT * FROM ll_pda_inventory_move WHERE overseas_warehouse_id=#{warehouseId} " +
"AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
PdaInventoryMove find(@Param("warehouseId") long warehouseId,
@Param("operatorKey") String operatorKey, @Param("key") String key);
@Select("SELECT * FROM ll_inventory_position WHERE overseas_warehouse_id=#{warehouseId} " +
"AND pallet_id=#{palletId} AND " +
"(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages)>0 " +
"ORDER BY customer_id,id FOR UPDATE")
List<InventoryPosition> lockPositions(@Param("warehouseId") String warehouseId,
@Param("palletId") long palletId);
}
@@ -0,0 +1,192 @@
package com.baiyue.lastleg.pda.move.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveResponse;
import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove;
import com.baiyue.lastleg.pda.move.mapper.PdaInventoryMoveMapper;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
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.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
@Service
public class PdaInventoryMoveService {
@Resource private PdaInventoryMoveMapper moves;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@Resource private InventoryLedgerMapper ledgers;
@Resource private WarehouseResourceGateway warehouse;
@Resource private PalletPhysicalGateway physical;
@Resource private PdaWarehouseService history;
@Resource private ObjectMapper objectMapper;
@Transactional
public PdaInventoryMoveResponse move(long warehouseId, String operatorKey, PdaInventoryMoveRequest request) {
String key = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
PdaInventoryMove existing = moves.find(warehouseId, operatorKey, key);
if (existing != null) return replay(existing, requestHash);
var pallet = warehouse.resolvePallet(required(request.sourcePalletCode(), "Source pallet code is required"));
if (pallet.warehouseId() != warehouseId) throw conflict("Source pallet belongs to another overseas warehouse");
var target = warehouse.resolveLocation(warehouseId,
required(request.targetLocationCode(), "Target location code is required"));
if (!Set.of("ACTIVE", "IDLE", "OCCUPIED").contains(target.status())) {
throw conflict("Target location is not available");
}
boolean recoveringRemoteSuccess = pallet.locationId() == target.locationId()
&& pallet.version() == request.expectedPalletVersion() + 1;
if (!recoveringRemoteSuccess && pallet.version() != request.expectedPalletVersion()) {
throw conflict("Source pallet version changed; rescan it");
}
List<InventoryPosition> inventory = moves.lockPositions(String.valueOf(warehouseId), pallet.palletId());
if (inventory.isEmpty()) throw conflict("Source pallet has no movable inventory");
Set<Long> inventoryLocations = new HashSet<>();
inventory.forEach(position -> inventoryLocations.add(position.getWarehouseLocationId()));
if (inventoryLocations.contains(null) || inventoryLocations.size() != 1) {
throw conflict("Inventory and warehouse pallet locations are inconsistent; reconcile before moving");
}
long sourceLocationId = inventoryLocations.iterator().next();
if (!recoveringRemoteSuccess && sourceLocationId != pallet.locationId()) {
throw conflict("Inventory and warehouse pallet locations are inconsistent; reconcile before moving");
}
if (sourceLocationId == target.locationId()) throw badRequest("Source and target locations must differ");
var source = warehouse.resolveLocation(warehouseId, sourceLocationId);
long packages = inventory.stream().mapToLong(PdaInventoryMoveService::physical).sum();
Set<String> customers = new HashSet<>();
inventory.forEach(position -> customers.add(position.getCustomerId()));
long moveId = IdWorker.getId();
PdaInventoryMove operation = new PdaInventoryMove();
operation.setId(moveId); operation.setMoveNo("MV-" + Long.toUnsignedString(moveId));
operation.setOverseasWarehouseId(warehouseId); operation.setPalletId(pallet.palletId());
operation.setPalletCode(pallet.palletNo()); operation.setSourceLocationId(source.locationId());
operation.setSourceLocationCode(source.locationCode()); operation.setTargetLocationId(target.locationId());
operation.setTargetLocationCode(target.locationCode()); operation.setCustomerCount(customers.size());
operation.setPositionCount(inventory.size()); operation.setPackageCount(packages);
operation.setPalletVersionBefore(pallet.version()); operation.setOperatorKey(operatorKey);
operation.setReason(request.reason().trim()); operation.setIdempotencyKey(key); operation.setRequestHash(requestHash);
operation.setStatus("PROCESSING"); operation.setCreatedAt(LocalDateTime.now());
try { moves.insert(operation); }
catch (DuplicateKeyException duplicate) {
PdaInventoryMove winner = moves.find(warehouseId, operatorKey, key);
if (winner == null) throw duplicate;
return replay(winner, requestHash);
}
String transitionKey = transitionKey(operatorKey, key, pallet.palletId());
var transitionSource = new PalletPhysicalGateway.PalletReference(pallet.palletId(), pallet.palletNo(),
warehouseId, source.locationId(), "OCCUPIED", pallet.fbaWarehouseCode(), request.expectedPalletVersion());
var relocated = physical.apply(warehouseId, transitionSource, "OCCUPIED", target.locationId(),
transitionKey, request.reason());
if (relocated.afterLocationId() != target.locationId()
|| !"APPLIED".equals(relocated.operationStatus())) {
throw new IllegalStateException("Warehouse relocation returned another target location");
}
LocalDateTime completedAt = LocalDateTime.now();
for (InventoryPosition position : inventory) {
InventoryBalance balance = balances.selectById(position.getBalanceId());
if (balance == null || !position.getCustomerId().equals(balance.getCustomerId())
|| !String.valueOf(warehouseId).equals(balance.getOverseasWarehouseId())) {
throw new IllegalStateException("Inventory balance scope is inconsistent during relocation");
}
position.setWarehouseLocationId(target.locationId()); position.setWarehouseLocationCode(target.locationCode());
position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(completedAt);
positions.updateById(position);
ledger(operation, position, balance, operatorKey, key, request.reason(), source.locationCode(),
target.locationCode(), completedAt);
}
operation.setPalletVersionAfter(relocated.afterVersion()); operation.setStatus("COMPLETED");
operation.setCompletedAt(completedAt);
PdaInventoryMoveResponse response = response(operation, false);
operation.setResultJson(write(response)); moves.updateById(operation);
history.record(warehouseId, null, operatorKey, "INVENTORY_MOVE", operation.getMoveNo(), "COMPLETED",
"INVENTORY_MOVE:" + key, requestHash,
pallet.palletNo() + ":" + source.locationCode() + " → " + target.locationCode()
+ "," + packages + " 箱");
return response;
}
private PdaInventoryMoveResponse replay(PdaInventoryMove operation, String requestHash) {
if (!requestHash.equals(operation.getRequestHash())) {
throw conflict("Idempotency key was reused with a different inventory move");
}
if (!"COMPLETED".equals(operation.getStatus()) || operation.getResultJson() == null) {
throw conflict("Inventory move is still processing");
}
try { return objectMapper.readValue(operation.getResultJson(), PdaInventoryMoveResponse.class).asReplay(); }
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot read inventory move replay", error); }
}
private PdaInventoryMoveResponse response(PdaInventoryMove value, boolean replayed) {
return new PdaInventoryMoveResponse(value.getMoveNo(), value.getPalletId(), value.getPalletCode(),
value.getSourceLocationId(), value.getSourceLocationCode(), value.getTargetLocationId(),
value.getTargetLocationCode(), value.getCustomerCount(), value.getPositionCount(),
value.getPackageCount(), value.getPalletVersionAfter(), value.getStatus(), value.getCompletedAt(), replayed);
}
private void ledger(PdaInventoryMove operation, InventoryPosition position, InventoryBalance balance,
String operator, String key, String reason, String source, String target,
LocalDateTime now) {
InventoryLedger row = new InventoryLedger(); row.setId(IdWorker.getId()); row.setCustomerId(position.getCustomerId());
row.setBalanceId(balance.getId()); row.setPositionId(position.getId());
row.setOverseasWarehouseId(String.valueOf(operation.getOverseasWarehouseId()));
row.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); row.setInboundOrderId(balance.getInboundOrderId());
row.setPalletId(position.getPalletId()); row.setPalletNo(position.getPalletNo()); row.setBatchNo(position.getBatchNo());
row.setSkuCode(balance.getSkuCode()); row.setWarehouseLocationCode(target); row.setBusinessType("INTERNAL_MOVE");
row.setBusinessOrderId(operation.getId()); row.setBusinessOrderNo(operation.getMoveNo());
row.setInboundCode(balance.getInboundCode()); row.setInboundOrderNo(balance.getInboundOrderNo());
row.setIdempotencyKey("MOVE:" + operation.getId() + ":" + position.getId());
row.setStatusFrom(source); row.setStatusTo(target); long packages = physical(position);
row.setBeforePackages(packages); row.setDeltaPackages(0L); row.setAfterPackages(packages);
row.setOperatorId(operator); row.setRemark(reason.trim()); row.setOccurredAt(now); row.setCreatedAt(now);
ledgers.insert(row);
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); }
}
private static String transitionKey(String operator, String key, long palletId) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(("INVENTORY_MOVE|" + operator + "|" + key + "|" + palletId)
.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); }
}
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot serialize inventory move", error); }
}
private static long physical(InventoryPosition value) { return value(value.getAvailablePackages()) + value(value.getHoldPackages())
+ value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); }
private static long value(Long value) { return value == null ? 0 : value; }
private static String required(String value, String message) { if (value == null || value.isBlank()) throw badRequest(message); return value.trim(); }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.pda.security;
import com.baiyue.common.security.AuthenticatedUser;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public record PdaWarehouseIdentity(String operatorKey, String operatorName, long overseasWarehouseId) {
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
public static final String PICKER_ROLE = "ROLE_WAREHOUSE_PICKER";
public static PdaWarehouseIdentity require(AuthenticatedUser user) {
if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0
|| !"PLATFORM".equals(user.appCode())) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,
"Authenticated platform warehouse PDA identity is required");
}
if (!user.permissions().contains(PICKER_ROLE)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "WAREHOUSE_PICKER role is required");
}
if (!user.permissions().contains(OPERATE_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
}
if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"PDA operator must be bound to one overseas warehouse");
}
String operator = String.valueOf(user.userId());
return new PdaWarehouseIdentity(operator,
user.username() == null || user.username().isBlank() ? operator : user.username(),
user.overseasWarehouseId());
}
}
@@ -0,0 +1,61 @@
package com.baiyue.lastleg.pda.stocktake.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
import com.baiyue.lastleg.pda.stocktake.service.PdaStocktakeService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
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.RestController;
@RestController
@RequestMapping("/api/pda/v1/last-leg/stocktakes")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')")
public class PdaStocktakeController {
@Resource private PdaStocktakeService service;
@GetMapping
public ApiResponse<PageResult<PdaStocktakeResponses.Summary>> page(
@AuthenticationPrincipal AuthenticatedUser user, @Valid PdaStocktakeRequests.TaskQuery query) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.page(identity.overseasWarehouseId(), query));
}
@PostMapping
public ApiResponse<PdaStocktakeResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaStocktakeRequests.Create request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.create(identity.overseasWarehouseId(), identity.operatorKey(), request));
}
@GetMapping("/{taskNo}")
public ApiResponse<PdaStocktakeResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String taskNo) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.detail(identity.overseasWarehouseId(), taskNo));
}
@PostMapping("/{taskNo}/counts")
public ApiResponse<PdaStocktakeResponses.Item> count(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String taskNo, @Valid @RequestBody PdaStocktakeRequests.Count request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.count(identity.overseasWarehouseId(), identity.operatorKey(), taskNo, request));
}
@PostMapping("/{taskNo}/submit")
public ApiResponse<PdaStocktakeResponses.Detail> submit(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String taskNo, @Valid @RequestBody PdaStocktakeRequests.Submit request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.submit(identity.overseasWarehouseId(), identity.operatorKey(), taskNo, request));
}
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.pda.stocktake.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public final class PdaStocktakeRequests {
private PdaStocktakeRequests() { }
public record TaskQuery(@Min(1) long page, @Min(1) @Max(100) long size, String status) {
public TaskQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record Create(@NotBlank @Size(max = 16) String scopeType,
@NotBlank @Size(max = 128) String scopeCode,
@NotBlank @Size(max = 128) String idempotencyKey) { }
public record Count(@NotNull Long positionId, @PositiveOrZero long actualPackages,
@PositiveOrZero long expectedVersion,
@NotBlank @Size(max = 128) String idempotencyKey) { }
public record Submit(@PositiveOrZero long expectedVersion,
@NotBlank @Size(max = 500) String reason,
@NotBlank @Size(max = 128) String idempotencyKey) { }
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.pda.stocktake.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class PdaStocktakeResponses {
private PdaStocktakeResponses() { }
public record Summary(String taskNo, String scopeType, String scopeCode, String status,
int positionCount, int countedCount, int differenceCount, long version,
LocalDateTime createdAt, LocalDateTime submittedAt) { }
public record Item(long itemId, String customerId, long balanceId, long positionId,
String skuCode, String productName, String inboundCode,
long palletId, String palletCode, long locationId, String locationCode,
long bookPackages, Long actualPackages, Long differencePackages,
String status, long positionVersion, long version) { }
public record Detail(Summary task, List<Item> items) { }
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.pda.stocktake.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_pda_stocktake_action")
public class PdaStocktakeAction {
@TableId(type = IdType.INPUT) private Long id;
private Long overseasWarehouseId;
private String operatorKey;
private String idempotencyKey;
private String actionType;
private Long taskId;
private String requestHash;
private String status;
private String resultJson;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.pda.stocktake.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_pda_stocktake_item")
public class PdaStocktakeItem {
@TableId(type = IdType.INPUT) private Long id;
private Long taskId;
private String customerId;
private Long balanceId;
private Long positionId;
private String skuCode;
private String productName;
private String inboundCode;
private Long palletId;
private String palletCode;
private Long locationId;
private String locationCode;
private Long bookPackages;
private Long actualPackages;
private Long differencePackages;
private String status;
private Long positionVersion;
private String countedBy;
private Long version;
private LocalDateTime countedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.pda.stocktake.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_pda_stocktake_task")
public class PdaStocktakeTask {
@TableId(type = IdType.INPUT) private Long id;
private String taskNo;
private Long overseasWarehouseId;
private String scopeType;
private Long scopeResourceId;
private String scopeCode;
private String status;
private Integer positionCount;
private Integer countedCount;
private Integer differenceCount;
private String createdBy;
private String submittedBy;
private String submitReason;
private String idempotencyKey;
private String requestHash;
private Long version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime submittedAt;
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.pda.stocktake.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeAction;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface PdaStocktakeActionMapper extends BaseMapper<PdaStocktakeAction> {
@Select("SELECT * FROM ll_pda_stocktake_action WHERE overseas_warehouse_id=#{warehouseId} " +
"AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
PdaStocktakeAction find(@Param("warehouseId") long warehouseId,
@Param("operatorKey") String operatorKey, @Param("key") String key);
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.pda.stocktake.mapper;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface PdaStocktakeInventoryMapper {
@Select("SELECT * FROM ll_inventory_position WHERE id=#{positionId} AND customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE")
InventoryPosition lockPosition(@Param("positionId") long positionId, @Param("customerId") String customerId,
@Param("warehouseId") String warehouseId);
@Select("SELECT * FROM ll_inventory_balance WHERE id=#{balanceId} AND customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE")
InventoryBalance lockBalance(@Param("balanceId") long balanceId, @Param("customerId") String customerId,
@Param("warehouseId") String warehouseId);
}
@@ -0,0 +1,66 @@
package com.baiyue.lastleg.pda.stocktake.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem;
import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate;
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;
@Mapper
public interface PdaStocktakeItemMapper extends BaseMapper<PdaStocktakeItem> {
String CANDIDATE = "SELECT p.customer_id,b.id balance_id,p.id position_id,b.sku_code," +
"COALESCE(NULLIF(b.product_name_zh,''),b.product_name_en) product_name,b.inbound_code," +
"p.pallet_id,p.pallet_no pallet_code,p.warehouse_location_id location_id," +
"p.warehouse_location_code location_code," +
"(p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages) book_packages," +
"p.version position_version FROM ll_inventory_position p JOIN ll_inventory_balance b " +
"ON b.id=p.balance_id AND b.customer_id=p.customer_id WHERE p.overseas_warehouse_id=#{warehouseId} ";
@Select(CANDIDATE + "AND p.pallet_id=#{palletId} AND " +
"(p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
"ORDER BY p.customer_id,p.id")
List<StocktakeCandidate> candidatesByPallet(@Param("warehouseId") String warehouseId,
@Param("palletId") long palletId);
@Select(CANDIDATE + "AND (p.warehouse_location_id=#{locationId} OR p.warehouse_location_code=#{locationCode}) " +
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
"ORDER BY p.customer_id,p.pallet_no,p.id")
List<StocktakeCandidate> candidatesByLocation(@Param("warehouseId") String warehouseId,
@Param("locationId") long locationId,
@Param("locationCode") String locationCode);
@Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} ORDER BY location_code,pallet_code,position_id")
List<PdaStocktakeItem> list(@Param("taskId") long taskId);
@Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} ORDER BY id FOR UPDATE")
List<PdaStocktakeItem> lockAll(@Param("taskId") long taskId);
@Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} AND position_id=#{positionId} LIMIT 1 FOR UPDATE")
PdaStocktakeItem lockItem(@Param("taskId") long taskId, @Param("positionId") long positionId);
@Update("UPDATE ll_pda_stocktake_item SET actual_packages=#{actualPackages}," +
"difference_packages=#{differencePackages},status=#{status},counted_by=#{operatorKey}," +
"counted_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1 " +
"WHERE id=#{itemId} AND version=#{expectedVersion} AND status IN ('PENDING','COUNTED')")
int countItem(@Param("itemId") long itemId, @Param("expectedVersion") long expectedVersion,
@Param("actualPackages") long actualPackages, @Param("differencePackages") long differencePackages,
@Param("status") String status, @Param("operatorKey") String operatorKey);
@Select("SELECT COUNT(*) FROM ll_pda_stocktake_item WHERE task_id=#{taskId} AND actual_packages IS NOT NULL")
int counted(@Param("taskId") long taskId);
@Select("SELECT COUNT(*) FROM ll_pda_stocktake_item WHERE task_id=#{taskId} " +
"AND actual_packages IS NOT NULL AND difference_packages&lt;&gt;0")
int differences(@Param("taskId") long taskId);
@Select("SELECT id item_id,customer_id,balance_id,position_id,sku_code,product_name,inbound_code," +
"pallet_id,pallet_code,location_id,location_code,book_packages,actual_packages,difference_packages," +
"status,position_version,version FROM ll_pda_stocktake_item WHERE task_id=#{taskId} " +
"ORDER BY location_code,pallet_code,position_id")
List<PdaStocktakeResponses.Item> views(@Param("taskId") long taskId);
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.pda.stocktake.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask;
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;
@Mapper
public interface PdaStocktakeTaskMapper extends BaseMapper<PdaStocktakeTask> {
@Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"AND created_by=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
PdaStocktakeTask findCreate(@Param("warehouseId") long warehouseId,
@Param("operatorKey") String operatorKey, @Param("key") String key);
@Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"AND task_no=#{taskNo} LIMIT 1")
PdaStocktakeTask find(@Param("warehouseId") long warehouseId, @Param("taskNo") String taskNo);
@Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"AND task_no=#{taskNo} LIMIT 1 FOR UPDATE")
PdaStocktakeTask lock(@Param("warehouseId") long warehouseId, @Param("taskNo") String taskNo);
@Select("<script>SELECT task_no,scope_type,scope_code,status,position_count,counted_count,difference_count," +
"version,created_at,submitted_at FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"<if test='status != null and status != \"\"'>AND status=#{status} </if>" +
"ORDER BY created_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<PdaStocktakeResponses.Summary> page(@Param("warehouseId") long warehouseId,
@Param("status") String status,
@Param("offset") long offset, @Param("limit") long limit);
@Select("<script>SELECT COUNT(*) FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"<if test='status != null and status != \"\"'>AND status=#{status}</if></script>")
long count(@Param("warehouseId") long warehouseId, @Param("status") String status);
@Update("UPDATE ll_pda_stocktake_task SET counted_count=#{countedCount},difference_count=#{differenceCount}," +
"version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{taskId} AND version=#{expectedVersion} " +
"AND status='IN_PROGRESS'")
int refreshCounts(@Param("taskId") long taskId, @Param("expectedVersion") long expectedVersion,
@Param("countedCount") int countedCount, @Param("differenceCount") int differenceCount);
@Update("UPDATE ll_pda_stocktake_task SET status='SUBMITTED',submitted_by=#{operatorKey}," +
"submit_reason=#{reason},submitted_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3)," +
"version=version+1 WHERE id=#{taskId} AND version=#{expectedVersion} AND status='IN_PROGRESS'")
int submit(@Param("taskId") long taskId, @Param("expectedVersion") long expectedVersion,
@Param("operatorKey") String operatorKey, @Param("reason") String reason);
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.pda.stocktake.model;
import lombok.Data;
@Data
public class StocktakeCandidate {
private String customerId;
private Long balanceId;
private Long positionId;
private String skuCode;
private String productName;
private String inboundCode;
private Long palletId;
private String palletCode;
private Long locationId;
private String locationCode;
private Long bookPackages;
private Long positionVersion;
}
@@ -0,0 +1,323 @@
package com.baiyue.lastleg.pda.stocktake.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeAction;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeActionMapper;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeInventoryMapper;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeItemMapper;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeTaskMapper;
import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
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.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
@Service
public class PdaStocktakeService {
@Resource private PdaStocktakeTaskMapper tasks;
@Resource private PdaStocktakeItemMapper items;
@Resource private PdaStocktakeActionMapper actions;
@Resource private PdaStocktakeInventoryMapper inventory;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@Resource private InventoryLedgerMapper ledgers;
@Resource private WarehouseResourceGateway warehouse;
@Resource private PdaWarehouseService history;
@Resource private ObjectMapper objectMapper;
public PageResult<PdaStocktakeResponses.Summary> page(long warehouseId, PdaStocktakeRequests.TaskQuery query) {
String status = status(query.status());
long offset = (query.page() - 1) * query.size();
List<PdaStocktakeResponses.Summary> rows = tasks.page(warehouseId, status, offset, query.size());
long total = tasks.count(warehouseId, status);
return new PageResult<>(rows, query.page(), query.size(), total,
total == 0 ? 0 : (total + query.size() - 1) / query.size());
}
public PdaStocktakeResponses.Detail detail(long warehouseId, String taskNo) {
PdaStocktakeTask task = requireTask(warehouseId, taskNo, false);
return detail(task);
}
@Transactional
public PdaStocktakeResponses.Detail create(long warehouseId, String operatorKey,
PdaStocktakeRequests.Create request) {
String key = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
PdaStocktakeTask replay = tasks.findCreate(warehouseId, operatorKey, key);
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with another stocktake");
return detail(replay);
}
String scopeType = required(request.scopeType(), "Scope type is required").toUpperCase();
String scopeCode = required(request.scopeCode(), "Scope code is required");
long resourceId;
List<StocktakeCandidate> candidates;
if ("PALLET".equals(scopeType)) {
var pallet = warehouse.resolvePallet(scopeCode);
if (pallet.warehouseId() != warehouseId) throw conflict("Pallet belongs to another overseas warehouse");
resourceId = pallet.palletId();
scopeCode = pallet.palletNo();
candidates = items.candidatesByPallet(String.valueOf(warehouseId), resourceId);
} else if ("LOCATION".equals(scopeType)) {
var location = warehouse.resolveLocation(warehouseId, scopeCode);
if (!List.of("ACTIVE", "IDLE", "OCCUPIED").contains(location.status())) {
throw conflict("Stocktake location is not active");
}
resourceId = location.locationId();
scopeCode = location.locationCode();
candidates = items.candidatesByLocation(String.valueOf(warehouseId), resourceId, scopeCode);
} else {
throw badRequest("scopeType must be LOCATION or PALLET");
}
if (candidates.isEmpty()) throw conflict("No inventory is available in the scanned scope");
long taskId = IdWorker.getId();
LocalDateTime now = LocalDateTime.now();
PdaStocktakeTask task = new PdaStocktakeTask();
task.setId(taskId); task.setTaskNo("CC-" + Long.toUnsignedString(taskId));
task.setOverseasWarehouseId(warehouseId); task.setScopeType(scopeType); task.setScopeResourceId(resourceId);
task.setScopeCode(scopeCode); task.setStatus("IN_PROGRESS"); task.setPositionCount(candidates.size());
task.setCountedCount(0); task.setDifferenceCount(0); task.setCreatedBy(operatorKey);
task.setIdempotencyKey(key); task.setRequestHash(requestHash); task.setVersion(0L);
task.setCreatedAt(now); task.setUpdatedAt(now);
try {
tasks.insert(task);
} catch (DuplicateKeyException duplicate) {
PdaStocktakeTask winner = tasks.findCreate(warehouseId, operatorKey, key);
if (winner == null || !requestHash.equals(winner.getRequestHash())) throw duplicate;
return detail(winner);
}
for (StocktakeCandidate candidate : candidates) {
PdaStocktakeItem item = new PdaStocktakeItem();
item.setId(IdWorker.getId()); item.setTaskId(taskId); item.setCustomerId(candidate.getCustomerId());
item.setBalanceId(candidate.getBalanceId()); item.setPositionId(candidate.getPositionId());
item.setSkuCode(candidate.getSkuCode()); item.setProductName(candidate.getProductName());
item.setInboundCode(candidate.getInboundCode()); item.setPalletId(candidate.getPalletId());
item.setPalletCode(candidate.getPalletCode()); item.setLocationId(candidate.getLocationId());
item.setLocationCode(candidate.getLocationCode()); item.setBookPackages(value(candidate.getBookPackages()));
item.setStatus("PENDING"); item.setPositionVersion(value(candidate.getPositionVersion()));
item.setVersion(0L); item.setCreatedAt(now); item.setUpdatedAt(now); items.insert(item);
}
history.record(warehouseId, null, operatorKey, "STOCKTAKE_CREATE", task.getTaskNo(), "IN_PROGRESS",
"STOCKTAKE_CREATE:" + key, requestHash,
scopeType + " " + scopeCode + ",共 " + candidates.size() + " 个库存位置");
return detail(task);
}
@Transactional
public PdaStocktakeResponses.Item count(long warehouseId, String operatorKey, String taskNo,
PdaStocktakeRequests.Count request) {
String key = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
PdaStocktakeAction action = beginAction(warehouseId, operatorKey, key, "COUNT", requestHash, taskNo);
if ("COMPLETED".equals(action.getStatus())) return read(action, PdaStocktakeResponses.Item.class);
PdaStocktakeTask task = requireTask(warehouseId, taskNo, true);
if (!"IN_PROGRESS".equals(task.getStatus())) throw conflict("Submitted stocktake cannot be changed");
PdaStocktakeItem item = items.lockItem(task.getId(), request.positionId());
if (item == null) throw notFound("Stocktake position was not found in this task");
long difference = request.actualPackages() - value(item.getBookPackages());
if (items.countItem(item.getId(), request.expectedVersion(), request.actualPackages(), difference,
difference == 0 ? "MATCHED" : "DIFFERENCE", operatorKey) != 1) {
throw conflict("Stocktake item version changed; refresh and retry");
}
int counted = items.counted(task.getId());
int differences = items.differences(task.getId());
if (tasks.refreshCounts(task.getId(), value(task.getVersion()), counted, differences) != 1) {
throw conflict("Stocktake task version changed; refresh and retry");
}
PdaStocktakeResponses.Item response = items.views(task.getId()).stream()
.filter(row -> row.positionId() == request.positionId()).findFirst().orElseThrow();
complete(action, response);
return response;
}
@Transactional
public PdaStocktakeResponses.Detail submit(long warehouseId, String operatorKey, String taskNo,
PdaStocktakeRequests.Submit request) {
String key = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
PdaStocktakeAction action = beginAction(warehouseId, operatorKey, key, "SUBMIT", requestHash, taskNo);
if ("COMPLETED".equals(action.getStatus())) return read(action, PdaStocktakeResponses.Detail.class);
PdaStocktakeTask task = requireTask(warehouseId, taskNo, true);
if (!"IN_PROGRESS".equals(task.getStatus())) throw conflict("Stocktake was already submitted");
if (value(task.getVersion()) != request.expectedVersion()) throw conflict("Stocktake task version changed; refresh and retry");
List<PdaStocktakeItem> taskItems = items.lockAll(task.getId());
if (taskItems.isEmpty() || taskItems.stream().anyMatch(item -> item.getActualPackages() == null)) {
throw conflict("Every stocktake position must be counted before submission");
}
Map<Long, InventoryBalance> changedBalances = new HashMap<>();
LocalDateTime now = LocalDateTime.now();
for (PdaStocktakeItem item : taskItems) {
InventoryPosition position = inventory.lockPosition(item.getPositionId(), item.getCustomerId(),
String.valueOf(warehouseId));
if (position == null || value(position.getVersion()) != value(item.getPositionVersion())) {
throw conflict("Inventory changed after counting; recreate the stocktake task");
}
long before = physical(position);
if (before != value(item.getBookPackages())) {
throw conflict("Inventory package total changed after counting; recreate the stocktake task");
}
long fixed = value(position.getHoldPackages()) + value(position.getAllocatedPackages())
+ value(position.getPickedPackages()) + value(position.getReceivingPackages());
if (value(item.getActualPackages()) < fixed) {
throw conflict("Actual packages cannot be lower than reserved or processing packages");
}
long newAvailable = value(item.getActualPackages()) - fixed;
long delta = newAvailable - value(position.getAvailablePackages());
InventoryBalance balance = changedBalances.get(item.getBalanceId());
if (balance == null) {
balance = inventory.lockBalance(item.getBalanceId(), item.getCustomerId(), String.valueOf(warehouseId));
if (balance == null) throw new IllegalStateException("Stocktake inventory balance is missing");
changedBalances.put(item.getBalanceId(), balance);
}
long balanceAfter = value(balance.getAvailablePackages()) + delta;
if (balanceAfter < 0) throw conflict("Stocktake adjustment would make inventory negative");
position.setAvailablePackages(newAvailable); position.setStatus(physicalAfter(position, newAvailable) == 0 ? "EMPTY" : "OCCUPIED");
position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(now); positions.updateById(position);
balance.setAvailablePackages(balanceAfter); balance.setVersion(value(balance.getVersion()) + 1); balance.setUpdatedAt(now);
ledger(task, item, position, balance, operatorKey, key, request.reason(), before, delta,
value(item.getActualPackages()), now);
item.setStatus(delta == 0 ? "MATCHED" : "ADJUSTED"); item.setUpdatedAt(now);
item.setVersion(value(item.getVersion()) + 1); items.updateById(item);
}
changedBalances.values().forEach(balances::updateById);
if (tasks.submit(task.getId(), request.expectedVersion(), operatorKey, request.reason()) != 1) {
throw conflict("Stocktake task version changed; refresh and retry");
}
history.record(warehouseId, null, operatorKey, "STOCKTAKE_SUBMIT", taskNo, "COMPLETED",
"STOCKTAKE_SUBMIT:" + key, requestHash,
"盘点提交,差异位置 " + task.getDifferenceCount() + " 个;原因:" + request.reason().trim());
PdaStocktakeResponses.Detail response = detail(requireTask(warehouseId, taskNo, false));
complete(action, response);
return response;
}
private PdaStocktakeResponses.Detail detail(PdaStocktakeTask task) {
return new PdaStocktakeResponses.Detail(summary(task), items.views(task.getId()));
}
private PdaStocktakeResponses.Summary summary(PdaStocktakeTask task) {
return new PdaStocktakeResponses.Summary(task.getTaskNo(), task.getScopeType(), task.getScopeCode(),
task.getStatus(), intValue(task.getPositionCount()), intValue(task.getCountedCount()),
intValue(task.getDifferenceCount()), value(task.getVersion()), task.getCreatedAt(), task.getSubmittedAt());
}
private PdaStocktakeTask requireTask(long warehouseId, String taskNo, boolean lock) {
String normalized = required(taskNo, "Stocktake task number is required");
PdaStocktakeTask task = lock ? tasks.lock(warehouseId, normalized) : tasks.find(warehouseId, normalized);
if (task == null) throw notFound("Stocktake task was not found in the bound warehouse");
return task;
}
private PdaStocktakeAction beginAction(long warehouseId, String operatorKey, String key, String type,
String requestHash, String taskNo) {
PdaStocktakeAction existing = actions.find(warehouseId, operatorKey, key);
if (existing != null) return replay(existing, type, requestHash);
PdaStocktakeAction action = new PdaStocktakeAction();
action.setId(IdWorker.getId()); action.setOverseasWarehouseId(warehouseId); action.setOperatorKey(operatorKey);
action.setIdempotencyKey(key); action.setActionType(type); action.setRequestHash(requestHash);
PdaStocktakeTask task = tasks.find(warehouseId, taskNo); action.setTaskId(task == null ? null : task.getId());
action.setStatus("PROCESSING"); action.setCreatedAt(LocalDateTime.now()); action.setUpdatedAt(action.getCreatedAt());
try { actions.insert(action); return action; }
catch (DuplicateKeyException duplicate) {
PdaStocktakeAction winner = actions.find(warehouseId, operatorKey, key);
if (winner == null) throw duplicate;
return replay(winner, type, requestHash);
}
}
private PdaStocktakeAction replay(PdaStocktakeAction action, String type, String requestHash) {
if (!type.equals(action.getActionType()) || !requestHash.equals(action.getRequestHash())) {
throw conflict("Idempotency key was reused with a different stocktake action");
}
if (!"COMPLETED".equals(action.getStatus())) throw conflict("Stocktake action is still processing");
return action;
}
private void complete(PdaStocktakeAction action, Object response) {
action.setStatus("COMPLETED"); action.setResultJson(write(response)); action.setUpdatedAt(LocalDateTime.now());
actions.updateById(action);
}
private <T> T read(PdaStocktakeAction action, Class<T> type) {
try { return objectMapper.readValue(action.getResultJson(), type); }
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot read stocktake replay", error); }
}
private void ledger(PdaStocktakeTask task, PdaStocktakeItem item, InventoryPosition position,
InventoryBalance balance, String operator, String key, String reason,
long before, long delta, long after, LocalDateTime now) {
InventoryLedger row = new InventoryLedger();
row.setId(IdWorker.getId()); row.setCustomerId(item.getCustomerId()); row.setBalanceId(balance.getId());
row.setPositionId(position.getId()); row.setOverseasWarehouseId(String.valueOf(task.getOverseasWarehouseId()));
row.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); row.setInboundOrderId(balance.getInboundOrderId());
row.setPalletId(position.getPalletId()); row.setPalletNo(position.getPalletNo()); row.setBatchNo(position.getBatchNo());
row.setSkuCode(item.getSkuCode()); row.setWarehouseLocationCode(position.getWarehouseLocationCode());
row.setBusinessType("STOCKTAKE_ADJUST"); row.setBusinessOrderId(task.getId()); row.setBusinessOrderNo(task.getTaskNo());
row.setInboundCode(balance.getInboundCode()); row.setInboundOrderNo(balance.getInboundOrderNo());
row.setIdempotencyKey("STOCKTAKE:" + task.getId() + ":" + item.getId());
row.setStatusFrom("PHYSICAL"); row.setStatusTo("PHYSICAL"); row.setBeforePackages(before);
row.setDeltaPackages(delta); row.setAfterPackages(after); row.setOperatorId(operator); row.setRemark(reason.trim());
row.setOccurredAt(now); row.setCreatedAt(now); ledgers.insert(row);
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); }
}
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot serialize stocktake operation", error); }
}
private static String status(String value) {
String normalized = trim(value); if (normalized == null) return null;
normalized = normalized.toUpperCase();
if (!List.of("IN_PROGRESS", "SUBMITTED").contains(normalized)) throw badRequest("Unsupported stocktake status");
return normalized;
}
private static long physical(InventoryPosition value) { return value(value.getAvailablePackages()) + value(value.getHoldPackages())
+ value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); }
private static long physicalAfter(InventoryPosition value, long available) { return available + value(value.getHoldPackages())
+ value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); }
private static long value(Long value) { return value == null ? 0 : value; }
private static int intValue(Integer value) { return value == null ? 0 : value; }
private static String required(String value, String message) { String normalized = trim(value); if (normalized == null) throw badRequest(message); return normalized; }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
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); }
}
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.pda.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseRequests;
import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseResponses;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
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.RestController;
@RestController
@RequestMapping("/api/pda/v1/last-leg")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')")
public class PdaWarehouseController {
@Resource private PdaWarehouseService service;
@PostMapping("/inventory/resolve")
public ApiResponse<PdaWarehouseResponses.InventoryScan> inventory(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaWarehouseRequests.Scan request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.inventory(identity.overseasWarehouseId(), request));
}
@PostMapping("/locations/resolve")
public ApiResponse<PdaWarehouseResponses.Location> location(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaWarehouseRequests.Scan request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.location(identity.overseasWarehouseId(), request));
}
@PostMapping("/pallets/resolve")
public ApiResponse<PdaWarehouseResponses.Pallet> pallet(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaWarehouseRequests.Scan request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.pallet(identity.overseasWarehouseId(), request));
}
@GetMapping("/operations")
public ApiResponse<PageResult<PdaWarehouseResponses.Operation>> operations(
@AuthenticationPrincipal AuthenticatedUser user, @Valid PdaWarehouseRequests.HistoryQuery query) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.history(identity.overseasWarehouseId(), identity.operatorKey(), query));
}
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.pda.warehouse.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public final class PdaWarehouseRequests {
private PdaWarehouseRequests() { }
public record Scan(@NotBlank @Size(max = 128) String code) { }
public record HistoryQuery(@Min(1) long page, @Min(1) @Max(200) long size, String operationType) {
public HistoryQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
}
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.pda.warehouse.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public final class PdaWarehouseResponses {
private PdaWarehouseResponses() { }
public record InventoryScan(String queryCode, List<InventoryItem> items) { }
public record InventoryItem(String customerId, String customerName, long balanceId, long positionId,
String skuCode, String productName, String inboundCode, String cartonMark,
long palletId, String palletCode, Long locationId, String locationCode,
long availablePackages, long holdPackages, long receivingPackages,
String status, long version) { }
public record Location(long locationId, String locationCode, String locationType, BigDecimal maxLoadKg,
String status, long occupiedPackages, int inventoryPositionCount) { }
public record Pallet(long palletId, String palletCode, String status, int version,
long locationId, String locationCode, long occupiedPackages,
int inventoryPositionCount, List<InventoryItem> inventory) { }
public record Operation(long operationId, String operationType, String businessKey, String status,
String summary, String customerId, LocalDateTime occurredAt) { }
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.pda.warehouse.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_pda_operation_history")
public class PdaOperationHistory {
@TableId(type = IdType.INPUT)
private Long id;
private Long overseasWarehouseId;
private String customerId;
private String operatorKey;
private String operationType;
private String businessKey;
private String status;
private String idempotencyKey;
private String requestHash;
private String summary;
private LocalDateTime occurredAt;
private LocalDateTime createdAt;
}
@@ -0,0 +1,72 @@
package com.baiyue.lastleg.pda.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory;
import com.baiyue.lastleg.pda.warehouse.model.PdaInventoryProjection;
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 PdaWarehouseMapper extends BaseMapper<PdaOperationHistory> {
String INVENTORY_SELECT = "SELECT DISTINCT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," +
"COALESCE(NULLIF(r.sku_code,''),b.sku_code) sku_code," +
"COALESCE(NULLIF(r.product_name_zh,''),b.product_name_zh) product_name_zh," +
"COALESCE(NULLIF(r.product_name_en,''),b.product_name_en) product_name_en,b.inbound_code," +
"c.carton_mark,p.pallet_id,p.pallet_no pallet_code,p.warehouse_location_id location_id," +
"p.warehouse_location_code location_code,p.available_packages,p.hold_packages,p.receiving_packages," +
"p.status,p.version FROM ll_inventory_balance b JOIN ll_inventory_position p " +
"ON p.balance_id=b.id AND p.customer_id=b.customer_id " +
"LEFT JOIN ll_inbound_stock_receipt r ON r.inventory_balance_id=b.id AND r.inventory_position_id=p.id " +
"LEFT JOIN ll_inbound_plan_carton c ON c.customer_id=b.customer_id AND c.order_id=b.inbound_order_id " +
"AND c.warehouse_pallet_id=p.pallet_id AND c.carton_mark=#{code} AND c.status='RECEIVED' ";
@Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} " +
"AND (UPPER(b.sku_code)=UPPER(#{code}) OR UPPER(r.sku_code)=UPPER(#{code}) " +
"OR b.inbound_code=#{code} OR c.carton_mark=#{code}) " +
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
"ORDER BY b.customer_id,b.inbound_code,p.pallet_no,p.id")
List<PdaInventoryProjection> scanInventory(@Param("warehouseId") String warehouseId,
@Param("code") String code);
@Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " +
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
"ORDER BY b.customer_id,b.inbound_code,p.id")
List<PdaInventoryProjection> inventoryByPallet(@Param("warehouseId") String warehouseId,
@Param("palletId") long palletId,
@Param("code") String ignored);
@Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} " +
"AND (p.warehouse_location_id=#{locationId} OR p.warehouse_location_code=#{locationCode}) " +
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
"ORDER BY b.customer_id,b.inbound_code,p.pallet_no,p.id")
List<PdaInventoryProjection> inventoryByLocation(@Param("warehouseId") String warehouseId,
@Param("locationId") long locationId,
@Param("locationCode") String locationCode,
@Param("code") String ignored);
@Select("<script>SELECT operation_id,operation_type,business_key,status,summary,customer_id,occurred_at FROM (" +
"SELECT h.id operation_id,h.operation_type,h.business_key,h.status,h.summary,h.customer_id,h.occurred_at " +
"FROM ll_pda_operation_history h WHERE h.overseas_warehouse_id=#{warehouseId} AND h.operator_key=#{operatorKey} " +
"<if test='operationType != null and operationType != \"\"'>AND h.operation_type=#{operationType} </if>" +
"UNION ALL SELECT o.id,CONCAT('RECEIVING_',o.action),s.container_no,'COMPLETED'," +
"CONCAT('柜号作业 ',s.container_no),s.customer_id,o.created_at " +
"FROM ll_receiving_container_operation o JOIN ll_receiving_container_session s ON s.id=o.session_id " +
"WHERE s.overseas_warehouse_id=#{warehouseId} AND o.operator_key=#{operatorKey} " +
"<if test='operationType != null and operationType != \"\"'>AND CONCAT('RECEIVING_',o.action)=#{operationType} </if>" +
") x ORDER BY occurred_at DESC,operation_id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseResponses.Operation> history(
@Param("warehouseId") long warehouseId, @Param("operatorKey") String operatorKey,
@Param("operationType") String operationType, @Param("offset") long offset, @Param("limit") long limit);
@Select("<script>SELECT COUNT(*) FROM (SELECT h.id FROM ll_pda_operation_history h " +
"WHERE h.overseas_warehouse_id=#{warehouseId} AND h.operator_key=#{operatorKey} " +
"<if test='operationType != null and operationType != \"\"'>AND h.operation_type=#{operationType} </if>" +
"UNION ALL SELECT o.id FROM ll_receiving_container_operation o JOIN ll_receiving_container_session s ON s.id=o.session_id " +
"WHERE s.overseas_warehouse_id=#{warehouseId} AND o.operator_key=#{operatorKey} " +
"<if test='operationType != null and operationType != \"\"'>AND CONCAT('RECEIVING_',o.action)=#{operationType} </if>) x</script>")
long countHistory(@Param("warehouseId") long warehouseId, @Param("operatorKey") String operatorKey,
@Param("operationType") String operationType);
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.pda.warehouse.model;
import lombok.Data;
@Data
public class PdaInventoryProjection {
private String customerId;
private String customerName;
private Long balanceId;
private Long positionId;
private String skuCode;
private String productNameZh;
private String productNameEn;
private String inboundCode;
private String cartonMark;
private Long palletId;
private String palletCode;
private Long locationId;
private String locationCode;
private Long availablePackages;
private Long holdPackages;
private Long receivingPackages;
private String status;
private Long version;
}
@@ -0,0 +1,101 @@
package com.baiyue.lastleg.pda.warehouse.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseRequests;
import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseResponses;
import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory;
import com.baiyue.lastleg.pda.warehouse.mapper.PdaWarehouseMapper;
import com.baiyue.lastleg.pda.warehouse.model.PdaInventoryProjection;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
@Service
public class PdaWarehouseService {
@Resource private PdaWarehouseMapper mapper;
@Resource private WarehouseResourceGateway warehouse;
public PdaWarehouseResponses.InventoryScan inventory(long warehouseId, PdaWarehouseRequests.Scan request) {
String code = code(request.code());
List<PdaWarehouseResponses.InventoryItem> items = items(mapper.scanInventory(String.valueOf(warehouseId), code));
if (items.isEmpty()) throw notFound("Inventory code was not found in the bound warehouse");
return new PdaWarehouseResponses.InventoryScan(code, items);
}
public PdaWarehouseResponses.Location location(long warehouseId, PdaWarehouseRequests.Scan request) {
WarehouseResourceGateway.WarehouseLocationReference location = warehouse.resolveLocation(warehouseId,
code(request.code()));
List<PdaWarehouseResponses.InventoryItem> inventory = items(mapper.inventoryByLocation(
String.valueOf(warehouseId), location.locationId(), location.locationCode(), "__NO_CARTON__"));
long packages = inventory.stream().mapToLong(this::packages).sum();
int positions = (int) inventory.stream().map(PdaWarehouseResponses.InventoryItem::positionId).distinct().count();
return new PdaWarehouseResponses.Location(location.locationId(), location.locationCode(),
location.locationType(), location.maxLoadKg(), location.status(), packages, positions);
}
public PdaWarehouseResponses.Pallet pallet(long warehouseId, PdaWarehouseRequests.Scan request) {
WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(code(request.code()));
if (pallet.warehouseId() != warehouseId) throw conflict("Pallet belongs to another overseas warehouse");
WarehouseResourceGateway.WarehouseLocationReference location = warehouse.resolveLocation(warehouseId,
pallet.locationId());
List<PdaWarehouseResponses.InventoryItem> inventory = items(mapper.inventoryByPallet(
String.valueOf(warehouseId), pallet.palletId(), "__NO_CARTON__"));
long packages = inventory.stream().mapToLong(this::packages).sum();
int positions = (int) inventory.stream().map(PdaWarehouseResponses.InventoryItem::positionId).distinct().count();
return new PdaWarehouseResponses.Pallet(pallet.palletId(), pallet.palletNo(), pallet.status(), pallet.version(),
location.locationId(), location.locationCode(), packages, positions, inventory);
}
public PageResult<PdaWarehouseResponses.Operation> history(long warehouseId, String operatorKey,
PdaWarehouseRequests.HistoryQuery query) {
long offset = (query.page() - 1) * query.size();
List<PdaWarehouseResponses.Operation> rows = mapper.history(warehouseId, operatorKey,
trim(query.operationType()), offset, query.size());
long total = mapper.countHistory(warehouseId, operatorKey, trim(query.operationType()));
return new PageResult<>(rows, query.page(), query.size(), total,
total == 0 ? 0 : (total + query.size() - 1) / query.size());
}
public void record(long warehouseId, String customerId, String operatorKey, String operationType,
String businessKey, String status, String idempotencyKey, String requestHash, String summary) {
PdaOperationHistory history = new PdaOperationHistory();
history.setId(IdWorker.getId()); history.setOverseasWarehouseId(warehouseId);
history.setCustomerId(trim(customerId)); history.setOperatorKey(operatorKey);
history.setOperationType(operationType); history.setBusinessKey(businessKey); history.setStatus(status);
history.setIdempotencyKey(trim(idempotencyKey)); history.setRequestHash(trim(requestHash));
history.setSummary(trim(summary)); history.setOccurredAt(LocalDateTime.now()); history.setCreatedAt(history.getOccurredAt());
mapper.insert(history);
}
private List<PdaWarehouseResponses.InventoryItem> items(List<PdaInventoryProjection> values) {
LinkedHashMap<String, PdaWarehouseResponses.InventoryItem> result = new LinkedHashMap<>();
for (PdaInventoryProjection value : values) {
String key = value.getPositionId() + ":" + text(value.getSkuCode()) + ":" + text(value.getCartonMark());
result.putIfAbsent(key, new PdaWarehouseResponses.InventoryItem(value.getCustomerId(), value.getCustomerName(),
number(value.getBalanceId()), number(value.getPositionId()), value.getSkuCode(),
first(value.getProductNameZh(), value.getProductNameEn()), value.getInboundCode(), value.getCartonMark(),
number(value.getPalletId()), value.getPalletCode(), value.getLocationId(), value.getLocationCode(),
number(value.getAvailablePackages()), number(value.getHoldPackages()), number(value.getReceivingPackages()),
value.getStatus(), number(value.getVersion())));
}
return List.copyOf(result.values());
}
private long packages(PdaWarehouseResponses.InventoryItem value) {
return value.availablePackages() + value.holdPackages() + value.receivingPackages();
}
private static String code(String value) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Scan code is required"); return result; }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String text(String value) { return value == null ? "" : value; }
private static String first(String first, String second) { String value = trim(first); return value == null ? trim(second) : value; }
private static long number(Long value) { return value == null ? 0 : value; }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -36,7 +36,11 @@ public final class OverseasProcurementRequests {
String productName, String status, String purchaseWarehouseId,
LocalDateTime createdFrom, LocalDateTime createdTo) {}
public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {}
public record Review(
@NotNull @Min(0) Long version,
@NotBlank String decision,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
public record Update(
@NotNull @Min(0) Long version,
@@ -22,7 +22,7 @@ public final class OverseasProcurementResponses {
public record History(
String operationType, String fromStatus, String toStatus, String operatorId,
String changeDetail, String remark,
String idempotencyKey, String changeDetail, String remark,
LocalDateTime createdAt) {}
public record Detail(
@@ -1,6 +1,8 @@
package com.baiyue.lastleg.procurement.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 lombok.Data;
@@ -17,29 +19,31 @@ public class OverseasProcurementOrder {
private String orderNo;
private String purchaseCategory;
private String productName;
private String productSpecification;
private String productUrl;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String productSpecification;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String productUrl;
private Long purchaseQuantity;
private BigDecimal budgetUnitPrice;
private String currency;
private String receivingAddress;
private String contactName;
private String contactPhone;
private String purchaseWarehouseId;
private String purchaseWarehouseName;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseId;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseName;
private Long inboundQuantity;
private String domesticCarrier;
private String domesticTrackingNo;
private String overseasCarrier;
private String overseasTrackingNo;
private LocalDateTime estimatedArrivalAt;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticCarrier;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticTrackingNo;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String overseasCarrier;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String overseasTrackingNo;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime estimatedArrivalAt;
private String status;
private String remark;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark;
private String idempotencyKey;
private String requestHash;
private String createdBy;
private String reviewedBy;
private String reviewRemark;
private String reviewIdempotencyKey;
private String reviewRequestHash;
private LocalDateTime reviewedAt;
private Long version;
private LocalDateTime createdAt;
@@ -17,6 +17,7 @@ public class OverseasProcurementStatusHistory {
private String fromStatus;
private String toStatus;
private String operatorId;
private String idempotencyKey;
private String changeDetail;
private String remark;
private LocalDateTime createdAt;
@@ -89,7 +89,8 @@ public class OverseasProcurementService {
if (concurrent != null) return replay(customerId, concurrent, requestHash);
throw exception;
}
history(order, "CREATE", null, PENDING_REVIEW, operatorId, null, request.remark(), now);
history(order, "CREATE", null, PENDING_REVIEW, operatorId,
key, null, request.remark(), now);
return detail(customerId, order.getOrderNo());
}
@@ -144,7 +145,8 @@ public class OverseasProcurementService {
order.getCreatedBy(), order.getReviewedBy(), order.getReviewRemark(), order.getReviewedAt(),
histories.listOrder(customerId, order.getId()).stream().map(value ->
new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(),
value.getToStatus(), value.getOperatorId(), value.getChangeDetail(), value.getRemark(),
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
value.getChangeDetail(), value.getRemark(),
value.getCreatedAt())).toList());
}
@@ -157,16 +159,27 @@ public class OverseasProcurementService {
default -> throw badRequest("Review decision must be APPROVE or REJECT");
};
OverseasProcurementOrder order = lock(customerId, orderNo);
if (next.equals(order.getStatus())) return detail(customerId, orderNo);
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
if (key.equals(order.getReviewIdempotencyKey())) {
if (!requestHash.equals(order.getReviewRequestHash())) {
throw conflict("Review idempotency key was reused with a different payload");
}
return detail(customerId, orderNo);
}
if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry");
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Overseas procurement order was already reviewed");
LocalDateTime now = LocalDateTime.now();
order.setStatus(next);
order.setReviewedBy(operatorId);
order.setReviewRemark(trim(request.remark()));
order.setReviewIdempotencyKey(key);
order.setReviewRequestHash(requestHash);
order.setReviewedAt(now);
touch(order, now);
orders.updateById(order);
history(order, "REVIEW", PENDING_REVIEW, next, operatorId, null, request.remark(), now);
history(order, "REVIEW", PENDING_REVIEW, next, operatorId,
key, null, request.remark(), now);
return detail(customerId, orderNo);
}
@@ -197,7 +210,7 @@ public class OverseasProcurementService {
touch(order, now);
orders.updateById(order);
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
changeDetail(before, after), null, now);
null, changeDetail(before, after), null, now);
return detail(customerId, orderNo);
}
@@ -224,7 +237,8 @@ public class OverseasProcurementService {
}
private void history(OverseasProcurementOrder order, String operationType, String from, String to,
String operator, String changeDetail, String remark, LocalDateTime now) {
String operator, String idempotencyKey, String changeDetail,
String remark, LocalDateTime now) {
OverseasProcurementStatusHistory history = new OverseasProcurementStatusHistory();
history.setId(IdWorker.getId());
history.setOrderId(order.getId());
@@ -233,6 +247,7 @@ public class OverseasProcurementService {
history.setFromStatus(from);
history.setToStatus(to);
history.setOperatorId(operator);
history.setIdempotencyKey(trim(idempotencyKey));
history.setChangeDetail(changeDetail);
history.setRemark(trim(remark));
history.setCreatedAt(now);
@@ -25,9 +25,10 @@ public class RelabelApplicationController {
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_CREATE + "')")
@Operation(summary = "Create a relabel application", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_CREATE)
public ApiResponse<RelabelResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId,
@Valid @RequestBody RelabelRequests.Create request) {
requirePlatform(user);
return ApiResponse.success(service.create("0", operator(user), request));
return ApiResponse.success(service.create(requiredCustomer(customerId), operator(user), request));
}
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
@@ -60,5 +61,12 @@ public class RelabelApplicationController {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required");
}
}
private String requiredCustomer(String customerId) {
String value = customerId == null ? null : customerId.trim();
if (value == null || value.isEmpty() || "0".equals(value)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Target customerId is required");
}
return value;
}
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
}

Some files were not shown because too many files have changed in this diff Show More