feat: gate overseas receiving and harden phase4 workflows
This commit is contained in:
+8
-3
@@ -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,17 +39,20 @@ 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",
|
||||
description = "V2 orders[].destinationType is FBA or OVERSEAS. FBA may use package counts; OVERSEAS requires one carton mark per loaded package. Legacy events are accepted only when the destination type can be inferred safely.")
|
||||
@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 = EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType())
|
||||
? shippingNodeConsumer.consume(event) : consumer.consume(event);
|
||||
return ApiResponse.success(consumed);
|
||||
}
|
||||
|
||||
@GetMapping("/api/platform/v1/last-leg/orders")
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ import java.util.List;
|
||||
@RestController
|
||||
@RequestMapping("/api/pda/v1/last-leg/receiving-containers")
|
||||
@Tag(name = "Overseas PDA container receiving")
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate')")
|
||||
@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;
|
||||
|
||||
+28
@@ -114,6 +114,33 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
|
||||
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()
|
||||
@@ -147,4 +174,5 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
|
||||
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) {}
|
||||
}
|
||||
|
||||
+8
@@ -8,6 +8,12 @@ public interface WarehouseResourceGateway {
|
||||
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");
|
||||
}
|
||||
@@ -21,4 +27,6 @@ public interface WarehouseResourceGateway {
|
||||
|
||||
record WarehouseReference(long warehouseId, String warehouseCode, String warehouseName,
|
||||
String status) { }
|
||||
record WarehouseLocationReference(long locationId, String locationCode, String locationType,
|
||||
java.math.BigDecimal maxLoadKg, String status) { }
|
||||
}
|
||||
|
||||
+18
-2
@@ -27,6 +27,7 @@ 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;
|
||||
@@ -67,6 +68,7 @@ public class ReceivingContainerService {
|
||||
@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;
|
||||
@@ -104,7 +106,8 @@ public class ReceivingContainerService {
|
||||
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) {
|
||||
List<InboundPlan> containerPlans = requirePlans(containerNo);
|
||||
String customerId = requireSingleCustomer(containerPlans, containerNo);
|
||||
if (!eligible(containerPlans, overseasWarehouseId)) throw notFound("Container is assigned to another overseas warehouse");
|
||||
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())) {
|
||||
@@ -130,7 +133,8 @@ public class ReceivingContainerService {
|
||||
}
|
||||
List<InboundPlan> containerPlans = requirePlans(containerNo);
|
||||
String customerId = requireSingleCustomer(containerPlans, containerNo);
|
||||
if (!eligible(containerPlans, overseasWarehouseId)) throw conflict("Container is assigned to another overseas warehouse");
|
||||
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);
|
||||
@@ -641,6 +645,12 @@ public class ReceivingContainerService {
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -650,6 +660,12 @@ public class ReceivingContainerService {
|
||||
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();
|
||||
|
||||
+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);
|
||||
|
||||
+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));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -11,7 +11,7 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface PdaWarehouseMapper extends BaseMapper<PdaOperationHistory> {
|
||||
String INVENTORY_SELECT = "SELECT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," +
|
||||
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," +
|
||||
|
||||
+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(
|
||||
|
||||
+2
@@ -42,6 +42,8 @@ public class OverseasProcurementOrder {
|
||||
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);
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
|
||||
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -41,6 +42,7 @@ public class LoadingCompletedConsumer {
|
||||
@Resource private LastlegOrderNoGenerator orderNoGenerator;
|
||||
@Resource private DropshipOrderService dropshipOrders;
|
||||
@Resource private InventoryCommandService inventory;
|
||||
@Resource private ContainerShippingGateMapper shippingGates;
|
||||
|
||||
@Transactional(isolation = Isolation.READ_COMMITTED)
|
||||
public int consume(EventEnvelope envelope) {
|
||||
@@ -71,6 +73,9 @@ public class LoadingCompletedConsumer {
|
||||
return countOrders(envelope,payload.containerNo());
|
||||
}
|
||||
|
||||
shippingGates.initialize(payload.containerBookingId(), envelope.tenantId(), "",
|
||||
payload.containerNo(), envelope.aggregateVersion(), envelope.handoverVersion());
|
||||
|
||||
String orderNo = orderNoGenerator.forContainerNo(payload.containerNo());
|
||||
JsonNode rawOrders = envelope.payload().path("orders");
|
||||
for (int index = 0; index < payload.orders().size(); index++) {
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.lastleg.shipping.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_container_shipping_gate")
|
||||
public class ContainerShippingGate {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private String containerBookingId;
|
||||
private String customerId;
|
||||
private String oceanBookingId;
|
||||
private String containerNo;
|
||||
private String status;
|
||||
private String standardNode;
|
||||
private Integer nodeSequence;
|
||||
private LocalDateTime observedAt;
|
||||
private LocalDateTime actualAt;
|
||||
private String locationCode;
|
||||
private String locationName;
|
||||
private String providerCode;
|
||||
private String providerEventId;
|
||||
private String providerNodeCode;
|
||||
private String sourceReference;
|
||||
private String rawPayloadHash;
|
||||
private Long aggregateVersion;
|
||||
private Long handoverVersion;
|
||||
private String lastEventId;
|
||||
private String payloadHash;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.lastleg.shipping.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface ContainerShippingGateMapper extends BaseMapper<ContainerShippingGate> {
|
||||
@Select("SELECT * FROM ll_container_shipping_gate WHERE container_booking_id=#{containerBookingId} FOR UPDATE")
|
||||
ContainerShippingGate lock(@Param("containerBookingId") String containerBookingId);
|
||||
|
||||
@Insert("INSERT IGNORE INTO ll_container_shipping_gate(container_booking_id,customer_id,ocean_booking_id," +
|
||||
"container_no,status,node_sequence,aggregate_version,handover_version,created_at,updated_at) VALUES(" +
|
||||
"#{containerBookingId},#{customerId},#{oceanBookingId},#{containerNo},'AWAITING_PICKUP_READY',0," +
|
||||
"#{aggregateVersion},#{handoverVersion},NOW(3),NOW(3))")
|
||||
int initialize(@Param("containerBookingId") String containerBookingId, @Param("customerId") String customerId,
|
||||
@Param("oceanBookingId") String oceanBookingId, @Param("containerNo") String containerNo,
|
||||
@Param("aggregateVersion") long aggregateVersion, @Param("handoverVersion") long handoverVersion);
|
||||
|
||||
@Select("SELECT CASE WHEN COUNT(DISTINCT o.source_container_booking_id)=1 " +
|
||||
"AND MIN(CASE WHEN g.status='RECEIVING_AVAILABLE' THEN 1 ELSE 0 END)=1 THEN 1 ELSE 0 END " +
|
||||
"FROM ll_order o LEFT JOIN ll_container_shipping_gate g " +
|
||||
"ON g.container_booking_id=o.source_container_booking_id AND g.customer_id=o.customer_id " +
|
||||
"WHERE o.customer_id=#{customerId} AND o.container_no=#{containerNo}")
|
||||
int receivingAvailable(@Param("customerId") String customerId, @Param("containerNo") String containerNo);
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package com.baiyue.lastleg.shipping.service;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.FirstLegContainerShippingNodeRecordedV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.lastleg.entity.LastlegEventInbox;
|
||||
import com.baiyue.lastleg.mapper.LastlegEventInboxMapper;
|
||||
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
|
||||
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Isolation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
@Service
|
||||
public class ContainerShippingNodeConsumer {
|
||||
public static final int PICKUP_READY_SEQUENCE = 80;
|
||||
@Resource private LastlegEventInboxMapper inboxes;
|
||||
@Resource private ContainerShippingGateMapper gates;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
@Transactional(isolation = Isolation.READ_COMMITTED)
|
||||
public int consume(EventEnvelope envelope) {
|
||||
validate(envelope);
|
||||
FirstLegContainerShippingNodeRecordedV1 payload = payload(envelope);
|
||||
validateEnvelopePayload(envelope, payload);
|
||||
LastlegEventInbox existing = inboxes.selectById(envelope.eventId());
|
||||
if (existing != null) {
|
||||
if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) {
|
||||
throw new IllegalArgumentException("船节点事件摘要冲突");
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
LastlegEventInbox inbox = inbox(envelope);
|
||||
try { inboxes.insert(inbox); }
|
||||
catch (DuplicateKeyException raced) {
|
||||
LastlegEventInbox concurrent = inboxes.selectById(envelope.eventId());
|
||||
if (concurrent == null || !concurrent.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) {
|
||||
throw new IllegalArgumentException("船节点事件摘要冲突", raced);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
gates.initialize(payload.containerBookingId(), payload.customerId(), payload.oceanBookingId(),
|
||||
payload.containerNo(), 0, 1);
|
||||
ContainerShippingGate gate = gates.lock(payload.containerBookingId());
|
||||
if (gate == null || !payload.customerId().equals(gate.getCustomerId())
|
||||
|| !payload.containerNo().equals(gate.getContainerNo())) {
|
||||
throw new IllegalArgumentException("船节点柜号快照与装柜交接不一致");
|
||||
}
|
||||
apply(gate, envelope, payload);
|
||||
gates.updateById(gate);
|
||||
inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxes.updateById(inbox);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private void apply(ContainerShippingGate gate, EventEnvelope envelope,
|
||||
FirstLegContainerShippingNodeRecordedV1 payload) {
|
||||
long currentVersion = gate.getAggregateVersion() == null ? 0 : gate.getAggregateVersion();
|
||||
int currentSequence = gate.getNodeSequence() == null ? 0 : gate.getNodeSequence();
|
||||
LocalDateTime actualAt = LocalDateTime.ofInstant(payload.actualAt(), ZoneOffset.UTC);
|
||||
boolean newerVersion = envelope.aggregateVersion() >= currentVersion;
|
||||
boolean newerNode = payload.nodeSequence() > currentSequence;
|
||||
boolean laterSameNode = payload.nodeSequence() == currentSequence
|
||||
&& (gate.getActualAt() == null || actualAt.isAfter(gate.getActualAt()));
|
||||
boolean accepted = newerVersion && (newerNode || laterSameNode);
|
||||
if (accepted) {
|
||||
gate.setOceanBookingId(payload.oceanBookingId());
|
||||
gate.setStandardNode(payload.standardNode()); gate.setNodeSequence(payload.nodeSequence());
|
||||
gate.setObservedAt(LocalDateTime.ofInstant(payload.observedAt(), ZoneOffset.UTC)); gate.setActualAt(actualAt);
|
||||
gate.setLocationCode(payload.locationCode()); gate.setLocationName(payload.locationName());
|
||||
gate.setProviderCode(payload.providerCode()); gate.setProviderEventId(payload.providerEventId());
|
||||
gate.setProviderNodeCode(payload.providerNodeCode()); gate.setSourceReference(payload.sourceReference());
|
||||
gate.setRawPayloadHash(payload.rawPayloadHash()); gate.setLastEventId(envelope.eventId());
|
||||
gate.setPayloadHash(envelope.payloadHash());
|
||||
}
|
||||
if (accepted && payload.nodeSequence() >= PICKUP_READY_SEQUENCE) {
|
||||
gate.setStatus("RECEIVING_AVAILABLE");
|
||||
}
|
||||
gate.setAggregateVersion(Math.max(currentVersion, envelope.aggregateVersion()));
|
||||
gate.setHandoverVersion(Math.max(gate.getHandoverVersion() == null ? 0 : gate.getHandoverVersion(),
|
||||
envelope.handoverVersion()));
|
||||
gate.setUpdatedAt(LocalDateTime.now());
|
||||
}
|
||||
|
||||
private void validate(EventEnvelope envelope) {
|
||||
envelope.requireValid();
|
||||
if (!EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(envelope.eventType())
|
||||
|| envelope.eventVersion() != 1 || envelope.sourceRegion() != RegionCode.CN
|
||||
|| envelope.targetRegion() != RegionCode.OVERSEAS
|
||||
|| !"CONTAINER_BOOKING".equals(envelope.aggregateType())
|
||||
|| envelope.handoverVersion() == null
|
||||
|| envelope.handoverVersion() != envelope.aggregateVersion() + 1) {
|
||||
throw new IllegalArgumentException("船节点交接信封无效");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateEnvelopePayload(EventEnvelope envelope, FirstLegContainerShippingNodeRecordedV1 payload) {
|
||||
if (!envelope.tenantId().equals(payload.customerId())
|
||||
|| !envelope.aggregateId().equals(payload.containerBookingId())) {
|
||||
throw new IllegalArgumentException("船节点交接客户或聚合标识不一致");
|
||||
}
|
||||
}
|
||||
|
||||
private FirstLegContainerShippingNodeRecordedV1 payload(EventEnvelope envelope) {
|
||||
try { return objectMapper.treeToValue(envelope.payload(), FirstLegContainerShippingNodeRecordedV1.class); }
|
||||
catch (Exception exception) { throw new IllegalArgumentException("船节点交接负载无效", exception); }
|
||||
}
|
||||
|
||||
private LastlegEventInbox inbox(EventEnvelope envelope) {
|
||||
LastlegEventInbox value = new LastlegEventInbox(); value.setEventId(envelope.eventId());
|
||||
value.setCustomerId(envelope.tenantId()); value.setEventType(envelope.eventType());
|
||||
value.setEventVersion(envelope.eventVersion()); value.setPayloadHash(envelope.payloadHash());
|
||||
value.setPayloadJson(envelope.payload().toString()); value.setStatus("RECEIVED");
|
||||
value.setReceivedAt(LocalDateTime.now()); return value;
|
||||
}
|
||||
}
|
||||
+76
-6
@@ -289,8 +289,7 @@ public class UsTruckOrderService {
|
||||
pallet.setUpdatedAt(now);
|
||||
pallets.updateById(pallet);
|
||||
}
|
||||
int allocated = (int) rows.stream().filter(row -> "ALLOCATED".equals(row.getStatus())).count()
|
||||
+ selected.size();
|
||||
int allocated = (int) rows.stream().filter(row -> "ALLOCATED".equals(row.getStatus())).count();
|
||||
int accepted = value(order.getReceivedPalletCount());
|
||||
if (accepted < 1 || allocated > accepted) throw conflict("Allocated pallet count exceeds accepted pallets");
|
||||
String from = order.getStatus();
|
||||
@@ -330,6 +329,69 @@ public class UsTruckOrderService {
|
||||
return command(customerId, orderNo, operatorId, "COMPLETE", IN_TRANSIT, COMPLETED, request);
|
||||
}
|
||||
|
||||
private UsTruckOrderResponses.Detail command(String customerId, String orderNo, String operatorId,
|
||||
String operationType, String expectedStatus, String nextStatus,
|
||||
UsTruckOrderRequests.Command request) {
|
||||
UsTruckOrder order = lock(customerId, orderNo);
|
||||
UsTruckOrderResponses.Detail replay = replayOperation(order, operationType,
|
||||
request.idempotencyKey(), request);
|
||||
if (replay != null) return replay;
|
||||
requireVersion(order, request.version());
|
||||
if (!expectedStatus.equals(order.getStatus())) {
|
||||
throw conflict("Truck order must be " + expectedStatus + " before " + operationType);
|
||||
}
|
||||
if (ALLOCATED.equals(expectedStatus)
|
||||
&& value(order.getAllocatedPalletCount()) != value(order.getReceivedPalletCount())) {
|
||||
throw conflict("All accepted pallets must be allocated before dispatch preparation");
|
||||
}
|
||||
return transition(order, operatorId, operationType, request.idempotencyKey(), request,
|
||||
expectedStatus, nextStatus, request.remark());
|
||||
}
|
||||
|
||||
private UsTruckOrderResponses.Detail transition(UsTruckOrder order, String operatorId,
|
||||
String operationType, String idempotencyKey, Object request,
|
||||
String from, String to, String remark) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
order.setStatus(to);
|
||||
touch(order);
|
||||
orders.updateById(order);
|
||||
recordOperation(order, operationType, idempotencyKey, request, operatorId, now);
|
||||
history(order, operationType, from, to, operatorId, idempotencyKey, remark, now);
|
||||
return detail(order.getCustomerId(), order.getOrderNo());
|
||||
}
|
||||
|
||||
private UsTruckOrderResponses.Detail replayOperation(UsTruckOrder order, String operationType,
|
||||
String idempotencyKey, Object request) {
|
||||
String key = idempotencyKey.trim();
|
||||
UsTruckOrderOperation existing = operations.findKey(order.getCustomerId(), order.getId(), key);
|
||||
if (existing == null) return null;
|
||||
if (!operationType.equals(existing.getOperationType()) || !hash(request).equals(existing.getRequestHash())) {
|
||||
throw conflict("Idempotency key was reused with a different truck operation payload");
|
||||
}
|
||||
return detail(order.getCustomerId(), order.getOrderNo());
|
||||
}
|
||||
|
||||
private void recordOperation(UsTruckOrder order, String operationType, String idempotencyKey,
|
||||
Object request, String operatorId, LocalDateTime now) {
|
||||
UsTruckOrderOperation operation = new UsTruckOrderOperation();
|
||||
operation.setId(IdWorker.getId());
|
||||
operation.setOrderId(order.getId());
|
||||
operation.setCustomerId(order.getCustomerId());
|
||||
operation.setOperationType(operationType);
|
||||
operation.setIdempotencyKey(idempotencyKey.trim());
|
||||
operation.setRequestHash(hash(request));
|
||||
operation.setResultVersion(order.getVersion());
|
||||
operation.setCreatedBy(operatorId);
|
||||
operation.setCreatedAt(now);
|
||||
operations.insert(operation);
|
||||
}
|
||||
|
||||
private void requireVersion(UsTruckOrder order, Long requestedVersion) {
|
||||
if (!requestedVersion.equals(value(order.getVersion()))) {
|
||||
throw conflict("Order was changed; refresh and retry");
|
||||
}
|
||||
}
|
||||
|
||||
private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); return o; }
|
||||
private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); }
|
||||
private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List<UsTruckOrderStatusHistory> hs) {
|
||||
@@ -338,15 +400,23 @@ public class UsTruckOrderService {
|
||||
.toList();
|
||||
return new UsTruckOrderResponses.Detail(summary(o), o.getOriginAddress(), o.getDestinationAddress(),
|
||||
o.getRemark(), o.getCreatedBy(), palletDetails,
|
||||
hs.stream().map(h -> new UsTruckOrderResponses.History(h.getFromStatus(), h.getToStatus(),
|
||||
h.getOperatorId(), h.getRemark(), h.getCreatedAt())).toList());
|
||||
hs.stream().map(h -> new UsTruckOrderResponses.History(h.getOperationType(),
|
||||
h.getFromStatus(), h.getToStatus(), h.getOperatorId(), h.getIdempotencyKey(),
|
||||
h.getRemark(), h.getCreatedAt())).toList());
|
||||
}
|
||||
private UsTruckOrderResponses.Pallet pallet(UsTruckOrderPallet p) {
|
||||
return new UsTruckOrderResponses.Pallet(p.getId(), p.getPalletNo(), value(p.getSequenceNo()),
|
||||
p.getSource(), p.getStatus(), p.getWarehousePalletId(), p.getPackageCount(), p.getReceivedAt());
|
||||
}
|
||||
private UsTruckOrderResponses.Summary summary(UsTruckOrder o) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getTransportType(), o.getOrigin(), o.getDestination(), o.getContainerType(), value(o.getPalletCount()), value(o.getPackageCount()), value(o.getReceivedPalletCount()), value(o.getAllocatedPalletCount()), value(o.getShippedPalletCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getContactName(), o.getContactInfo(), o.getRequestedDate(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt()); }
|
||||
private void history(UsTruckOrder o, String from, String to, String operator, String remark, LocalDateTime at) { UsTruckOrderStatusHistory h = new UsTruckOrderStatusHistory(); h.setId(IdWorker.getId()); h.setOrderId(o.getId()); h.setCustomerId(o.getCustomerId()); h.setFromStatus(from); h.setToStatus(to); h.setOperatorId(operator); h.setRemark(trim(remark)); h.setCreatedAt(at); histories.insert(h); }
|
||||
private UsTruckOrderResponses.Summary summary(UsTruckOrder o) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getTransportType(), o.getOrigin(), o.getDestination(), o.getContainerType(), value(o.getPalletCount()), value(o.getPackageCount()), value(o.getReceivedPalletCount()), value(o.getAllocatedPalletCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getContactName(), o.getContactInfo(), o.getRequestedDate(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt()); }
|
||||
private void history(UsTruckOrder o, String operationType, String from, String to, String operator,
|
||||
String idempotencyKey, String remark, LocalDateTime at) {
|
||||
UsTruckOrderStatusHistory h = new UsTruckOrderStatusHistory();
|
||||
h.setId(IdWorker.getId()); h.setOrderId(o.getId()); h.setCustomerId(o.getCustomerId());
|
||||
h.setOperationType(operationType); h.setFromStatus(from); h.setToStatus(to);
|
||||
h.setOperatorId(operator); h.setIdempotencyKey(trim(idempotencyKey)); h.setRemark(trim(remark));
|
||||
h.setCreatedAt(at); histories.insert(h);
|
||||
}
|
||||
private void createPlannedPallets(UsTruckOrder order) {
|
||||
int existing = pallets.listOrder(order.getCustomerId(), order.getId()).size();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
Reference in New Issue
Block a user