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:
@@ -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;
|
||||
}
|
||||
}
|
||||
+54
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-2
@@ -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;
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+11
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
-43
@@ -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());
|
||||
}
|
||||
}
|
||||
+136
@@ -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;
|
||||
}
|
||||
}
|
||||
+20
-2
@@ -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,
|
||||
|
||||
+26
@@ -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) { }
|
||||
}
|
||||
+52
@@ -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) { }
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.dto;
|
||||
|
||||
public enum ReceivingPhotoRole {
|
||||
LICENSE_PLATE,
|
||||
CONTAINER_SEAL,
|
||||
DOOR_OPEN
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
+30
@@ -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;
|
||||
}
|
||||
+38
-7
@@ -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) {}
|
||||
}
|
||||
|
||||
+97
-3
@@ -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) {}
|
||||
}
|
||||
|
||||
+22
-1
@@ -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) { }
|
||||
}
|
||||
|
||||
+15
@@ -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);
|
||||
}
|
||||
+22
@@ -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);
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
+1
@@ -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());
|
||||
}
|
||||
|
||||
+236
-14
@@ -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; }
|
||||
|
||||
+60
-2
@@ -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");
|
||||
|
||||
+15
-4
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+68
-9
@@ -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) {
|
||||
|
||||
+760
@@ -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) { }
|
||||
}
|
||||
+6
-2
@@ -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");
|
||||
}
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+10
-4
@@ -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;
|
||||
|
||||
+16
-13
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -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) > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_packages > 0</when>" +
|
||||
|
||||
+1
@@ -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) { }
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+22
@@ -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;
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
+26
-5
@@ -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);
|
||||
}
|
||||
|
||||
+6
-3
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+27
-5
@@ -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); }
|
||||
}
|
||||
|
||||
+103
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -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);
|
||||
}
|
||||
+121
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
+165
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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) {
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-58
@@ -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());
|
||||
}
|
||||
}
|
||||
-143
@@ -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); }
|
||||
}
|
||||
+58
@@ -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));
|
||||
}
|
||||
}
|
||||
+42
@@ -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) { }
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
+82
@@ -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) { }
|
||||
}
|
||||
+16
@@ -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) { }
|
||||
}
|
||||
+45
@@ -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)>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);
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
+506
@@ -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) { }
|
||||
}
|
||||
+15
@@ -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 {
|
||||
}
|
||||
+30
@@ -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));
|
||||
}
|
||||
}
|
||||
+13
@@ -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
|
||||
) { }
|
||||
+26
@@ -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);
|
||||
}
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
+192
@@ -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); }
|
||||
}
|
||||
+32
@@ -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());
|
||||
}
|
||||
}
|
||||
+61
@@ -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));
|
||||
}
|
||||
}
|
||||
+28
@@ -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) { }
|
||||
}
|
||||
+20
@@ -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) { }
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
+20
@@ -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);
|
||||
}
|
||||
+66
@@ -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<>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);
|
||||
}
|
||||
+51
@@ -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);
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
+323
@@ -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); }
|
||||
}
|
||||
+53
@@ -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));
|
||||
}
|
||||
}
|
||||
+15
@@ -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; }
|
||||
}
|
||||
}
|
||||
+23
@@ -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) { }
|
||||
}
|
||||
+26
@@ -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;
|
||||
}
|
||||
+72
@@ -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);
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
+101
@@ -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); }
|
||||
}
|
||||
+5
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+14
-10
@@ -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;
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+21
-6
@@ -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);
|
||||
|
||||
+9
-1
@@ -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
Reference in New Issue
Block a user