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();
|
||||
|
||||
@@ -18,6 +18,23 @@ CREATE TABLE IF NOT EXISTS ll_loading_event_inbox (
|
||||
UNIQUE KEY uk_ll_loading_inbox_tenant_key (customer_id,event_type,event_id)
|
||||
) ENGINE=InnoDB COMMENT='头程装柜完成事件接收幂等箱';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_container_shipping_gate (
|
||||
container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户标识',
|
||||
ocean_booking_id VARCHAR(128) NOT NULL COMMENT '来源海运订舱ID', container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE',
|
||||
standard_node VARCHAR(64) NULL COMMENT '最新有效标准船节点', node_sequence INT NOT NULL DEFAULT 0 COMMENT '最新有效节点单调序号',
|
||||
observed_at DATETIME(3) NULL COMMENT '供应商节点观测时间', actual_at DATETIME(3) NULL COMMENT '节点实际发生时间',
|
||||
location_code VARCHAR(128) NULL COMMENT '节点地点编码', location_name VARCHAR(255) NULL COMMENT '节点地点名称',
|
||||
provider_code VARCHAR(64) NULL COMMENT '船节点供应商编码', provider_event_id VARCHAR(128) NULL COMMENT '供应商事件ID',
|
||||
provider_node_code VARCHAR(128) NULL COMMENT '供应商原始节点编码', source_reference VARCHAR(255) NULL COMMENT '供应商来源引用',
|
||||
raw_payload_hash VARCHAR(64) NULL COMMENT '供应商原始负载SHA-256', aggregate_version BIGINT NOT NULL DEFAULT 0 COMMENT '来源查询聚合版本',
|
||||
handover_version BIGINT NOT NULL DEFAULT 1 COMMENT '跨区交接版本', last_event_id VARCHAR(128) NULL COMMENT '最近采纳的跨区事件ID',
|
||||
payload_hash VARCHAR(71) NULL COMMENT '最近采纳事件规范化负载摘要', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(container_booking_id), KEY idx_ll_shipping_gate_candidate(customer_id,container_no,status),
|
||||
KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_order (
|
||||
id BIGINT NOT NULL COMMENT '雪花 ID',
|
||||
order_no VARCHAR(32) NOT NULL COMMENT '尾程单号',
|
||||
@@ -715,6 +732,18 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_operation (
|
||||
UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_pda_operation_history (
|
||||
id BIGINT NOT NULL COMMENT 'PDA作业历史ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID',
|
||||
customer_id VARCHAR(128) NULL COMMENT '作业涉及的客户标识', operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识',
|
||||
operation_type VARCHAR(64) NOT NULL COMMENT '作业类型', business_key VARCHAR(255) NOT NULL COMMENT '作业业务键',
|
||||
status VARCHAR(32) NOT NULL COMMENT '作业结果状态', idempotency_key VARCHAR(255) NULL COMMENT '写操作幂等键',
|
||||
request_hash VARCHAR(128) NULL COMMENT '写请求内容哈希', summary VARCHAR(1000) NULL COMMENT '作业结果摘要',
|
||||
occurred_at DATETIME(3) NOT NULL COMMENT '作业发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_history_idempotency(operator_key,idempotency_key),
|
||||
KEY idx_ll_pda_history_operator(overseas_warehouse_id,operator_key,occurred_at),
|
||||
KEY idx_ll_pda_history_business(overseas_warehouse_id,operation_type,business_key)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA真实作业历史';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment (
|
||||
id BIGINT NOT NULL COMMENT '主键ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
@@ -1253,6 +1282,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_application (
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
|
||||
created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注',
|
||||
review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希',
|
||||
reviewed_at DATETIME(3) NULL COMMENT '审核时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_cargo_discard_no(customer_id,discard_no),
|
||||
@@ -1303,6 +1333,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
|
||||
estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态',
|
||||
remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
|
||||
created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注',
|
||||
review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希',
|
||||
reviewed_at DATETIME(3) NULL COMMENT '审核时间', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
@@ -1316,7 +1347,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE',
|
||||
from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
|
||||
change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注',
|
||||
idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
PRIMARY KEY(id), KEY idx_ll_overseas_procurement_history(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='海外采购操作流水';
|
||||
@@ -1335,7 +1366,6 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order (
|
||||
package_count INT NULL COMMENT '可选箱数,零或空值均有效',
|
||||
received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数',
|
||||
allocated_pallet_count INT NOT NULL DEFAULT 0 COMMENT '已分配到合车行程的托盘数',
|
||||
shipped_pallet_count INT NOT NULL DEFAULT 0 COMMENT '已出库托盘数',
|
||||
total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)',
|
||||
total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)',
|
||||
budget_amount DECIMAL(18,2) NULL COMMENT '预算金额',
|
||||
@@ -1345,8 +1375,6 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order (
|
||||
requested_date DATE NOT NULL COMMENT '要求服务日期',
|
||||
status VARCHAR(32) NOT NULL COMMENT '美国卡车订单状态',
|
||||
remark VARCHAR(1000) NULL COMMENT '备注',
|
||||
receipt_idempotency_key VARCHAR(255) NULL COMMENT '实物收货幂等键',
|
||||
receipt_request_hash CHAR(64) NULL COMMENT '实物收货请求哈希',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
|
||||
created_by VARCHAR(128) NULL COMMENT '创建人ID',
|
||||
@@ -1356,7 +1384,6 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order (
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_us_truck_order_no(customer_id,order_no),
|
||||
UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key),
|
||||
UNIQUE KEY uk_ll_us_truck_receipt_key(customer_id,receipt_idempotency_key),
|
||||
KEY idx_ll_us_truck_order_list(customer_id,status,created_at),
|
||||
KEY idx_ll_us_truck_order_date(customer_id,requested_date,created_at)
|
||||
) ENGINE=InnoDB COMMENT='美国卡车运输订单';
|
||||
@@ -1380,13 +1407,25 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet (
|
||||
KEY idx_ll_us_truck_order_pallet_list(customer_id,order_id,status)
|
||||
) ENGINE=InnoDB COMMENT='美国卡车订单托盘实收记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_us_truck_order_operation (
|
||||
id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '美国卡车订单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(64) NOT NULL COMMENT '具名操作类型',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作请求内容哈希',
|
||||
result_version BIGINT NOT NULL COMMENT '操作完成后的订单版本', created_by VARCHAR(128) NULL COMMENT '操作人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_us_truck_operation_key(customer_id,order_id,idempotency_key),
|
||||
KEY idx_ll_us_truck_operation_order(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='美国卡车订单幂等操作账';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history (
|
||||
id BIGINT NOT NULL COMMENT '主键ID',
|
||||
order_id BIGINT NOT NULL COMMENT '订单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
operation_type VARCHAR(64) NOT NULL COMMENT '具名操作类型',
|
||||
from_status VARCHAR(32) NULL COMMENT '变更前状态',
|
||||
to_status VARCHAR(32) NOT NULL COMMENT '变更后状态',
|
||||
operator_id VARCHAR(128) NULL COMMENT '操作人ID',
|
||||
idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键',
|
||||
remark VARCHAR(1000) NULL COMMENT '备注',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
PRIMARY KEY(id),
|
||||
@@ -1421,7 +1460,17 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_history (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、UPDATE或STATUS', from_status VARCHAR(32) NULL COMMENT '变更前状态',
|
||||
to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
|
||||
change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注',
|
||||
idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
KEY idx_ll_local_looseboard_history(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='本土散板操作流水';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_local_looseboard_operation (
|
||||
id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作请求内容哈希',
|
||||
result_version BIGINT NOT NULL COMMENT '操作完成后的订单版本', created_by VARCHAR(128) NULL COMMENT '操作人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_local_looseboard_operation_key(customer_id,order_id,idempotency_key),
|
||||
KEY idx_ll_local_looseboard_operation_order(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='本土散板订单幂等操作账';
|
||||
|
||||
@@ -6,7 +6,7 @@ paths:
|
||||
/api/pda/v1/last-leg/receiving-containers/candidates:
|
||||
get:
|
||||
summary: 查询当前客户待签收柜号
|
||||
description: 权限 lastleg:pda:operate;仅 PLATFORM WAREHOUSE_PICKER 可用,操作账号从 Token 获取,客户由所选柜业务事实唯一解析,不接收 customerId。
|
||||
description: 权限 lastleg:pda:operate;仅 PLATFORM WAREHOUSE_PICKER 可用;仅返回已单调消费 PICKUP_READY(80) 或更高实际船节点、状态为 RECEIVING_AVAILABLE 的柜,不接收 customerId。
|
||||
security: [{ bearerAuth: [] }]
|
||||
responses: { '200': { description: 柜号候选及箱数进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCandidateListApiResponse' } } } }, '403': { description: 缺少 PDA 操作权限 } }
|
||||
/api/pda/v1/last-leg/receiving-containers/{containerNo}:
|
||||
@@ -19,7 +19,7 @@ paths:
|
||||
/api/pda/v1/last-leg/receiving-containers/{containerNo}/prepare:
|
||||
post:
|
||||
summary: 选择当前待签收柜号
|
||||
description: 权限 lastleg:pda:operate;建立 WAIT_SIGN 会话并以平台操作账号、柜号全局双唯一键防止切柜或多人同时处理。
|
||||
description: 权限 lastleg:pda:operate;仅 RECEIVING_AVAILABLE 柜可建立 WAIT_SIGN 会话,并以平台操作账号、柜号全局双唯一键防止切柜或多人同时处理。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } }
|
||||
@@ -86,6 +86,37 @@ paths:
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } }
|
||||
responses: { '200': { description: COMPLETED 柜号会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 数量未核平或状态冲突 } }
|
||||
/api/pda/v1/last-leg/inventory/resolve:
|
||||
post:
|
||||
summary: PDA按SKU、箱唛或入仓码扫码查询库存
|
||||
description: 仅 WAREHOUSE_PICKER + lastleg:pda:operate;海外实际仓只取Token overseasWarehouseId,不接收客户或仓库参数。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PdaWarehouseScan' } } } }
|
||||
responses: { '200': { description: 同仓跨客户库存与物理仓位事实, content: { application/json: { schema: { $ref: '#/components/schemas/PdaInventoryScanApiResponse' } } } }, '404': { description: A0404:当前绑定仓无匹配库存 } }
|
||||
/api/pda/v1/last-leg/locations/resolve:
|
||||
post:
|
||||
summary: PDA查询库位容量、状态与库存占用
|
||||
description: 库位从Warehouse Overseas公共资源解析,并强制匹配Token绑定仓。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PdaWarehouseScan' } } } }
|
||||
responses: { '200': { description: 库位物理事实及Lastleg库存占用汇总, content: { application/json: { schema: { $ref: '#/components/schemas/PdaLocationApiResponse' } } } }, '404': { description: A0404:库位不存在或跨仓 } }
|
||||
/api/pda/v1/last-leg/pallets/resolve:
|
||||
post:
|
||||
summary: PDA查询托盘、库位与绑定库存
|
||||
description: 托盘从Warehouse Overseas公共资源解析;跨Token绑定仓返回A0409。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PdaWarehouseScan' } } } }
|
||||
responses: { '200': { description: 托盘物理事实及Lastleg绑定库存, content: { application/json: { schema: { $ref: '#/components/schemas/PdaPalletApiResponse' } } } }, '409': { description: A0409:托盘属于其他仓 } }
|
||||
/api/pda/v1/last-leg/operations:
|
||||
get:
|
||||
summary: 查询当前PDA账号真实作业历史
|
||||
description: 返回Token绑定仓内当前操作账号的柜号作业及盘点、移库、托盘、换标历史;不含出库或派送。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
|
||||
- { name: operationType, in: query, schema: { type: string } }
|
||||
responses: { '200': { description: 真实作业历史分页, content: { application/json: { schema: { $ref: '#/components/schemas/PdaOperationPageApiResponse' } } } } }
|
||||
/api/platform/v1/last-leg/orders:
|
||||
get:
|
||||
summary: 分页查询尾程订单
|
||||
@@ -269,14 +300,14 @@ paths:
|
||||
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/review:
|
||||
post:
|
||||
summary: 审核海外采购订单
|
||||
description: 权限 platform:overseas-business:purchase:review。
|
||||
description: 权限 platform:overseas-business:purchase:review;同幂等键同请求重放成功,异请求、旧 version 或终态反向审核返回 A0409。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: orderNo, in: path, required: true, schema: { type: string } }
|
||||
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
requestBody:
|
||||
required: true
|
||||
content: { application/json: { schema: { type: object, required: [decision], properties: { decision: { type: string, enum: [APPROVE, REJECT] }, remark: { type: string, nullable: true, maxLength: 1000 } } } } }
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementReview' } } }
|
||||
responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } }
|
||||
/api/merchant/v1/last-leg/overseas-procurements:
|
||||
post:
|
||||
@@ -325,14 +356,24 @@ paths:
|
||||
responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } }
|
||||
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/status:
|
||||
patch:
|
||||
summary: 推进本土卡车订单状态
|
||||
description: 权限 platform:overseas-business:truck:status;普通编辑接口不能修改状态。
|
||||
summary: 取消未发车的本土卡车订单
|
||||
description: 权限 platform:overseas-business:truck:status;该通用入口只接受 CANCELLED,正向状态必须使用具名命令,不能绕过实收、超托与分配事实。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: orderNo, in: path, required: true, schema: { type: string } }
|
||||
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [version, status], properties: { version: { type: integer, format: int64, minimum: 0 }, status: { $ref: '#/components/schemas/TruckStatus' }, remark: { type: string, nullable: true, maxLength: 1000 } } } } } }
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCancel' } } } }
|
||||
responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 }, '409': { description: 非法状态迁移 } }
|
||||
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/review:
|
||||
parameters: &truckOrderTarget
|
||||
- { name: orderNo, in: path, required: true, schema: { type: string } }
|
||||
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
post:
|
||||
summary: 审核本土卡车订单
|
||||
description: 权限 platform:overseas-business:truck:status;PENDING_REVIEW 审核通过进入 WAITING_INBOUND 并生成预期托盘;拒绝进入 REJECTED。幂等键异请求、旧 version 或非法跳转返回 A0409。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckReview' } } } }
|
||||
responses: { '200': { description: 卡车订单详情 }, '409': { description: 版本、状态或幂等冲突 } }
|
||||
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/receive-pallets:
|
||||
post:
|
||||
summary: 记录本土卡车订单实收托盘
|
||||
@@ -341,7 +382,56 @@ paths:
|
||||
parameters:
|
||||
- { name: orderNo, in: path, required: true, schema: { type: string } }
|
||||
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckReceivePallets' } } } }
|
||||
responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } }
|
||||
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/review-pallet-overage:
|
||||
parameters: *truckOrderTarget
|
||||
post:
|
||||
summary: 审核实收超出申报数的托盘
|
||||
description: 权限 platform:overseas-business:truck:status;APPROVE 将超收托盘纳入本单并进入 WAITING_CONSOLIDATION;REJECT 标记超收托盘拒收并按原申报数继续。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckReviewPalletOverage' } } } }
|
||||
responses: { '200': { description: 卡车订单详情 }, '409': { description: 版本、状态或幂等冲突 } }
|
||||
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/allocate-pallets:
|
||||
parameters: *truckOrderTarget
|
||||
post:
|
||||
summary: 分配已实收托盘到本土卡车行程
|
||||
description: 权限 platform:overseas-business:truck:status;仅接收本单 RECEIVED 托盘 ID;部分分配进入 PARTIALLY_ALLOCATED,全部分配进入 ALLOCATED。不创建海外仓出库单或扣减库存。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckAllocatePallets' } } } }
|
||||
responses: { '200': { description: 卡车订单详情 }, '409': { description: 托盘归属、状态、版本或幂等冲突 } }
|
||||
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/prepare-dispatch:
|
||||
parameters: *truckOrderTarget
|
||||
post:
|
||||
summary: 全部托盘分配后准备派车
|
||||
description: 权限 platform:overseas-business:truck:status;旧 version、幂等键异请求或非 ALLOCATED 状态返回 A0409。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCommand' } } } }
|
||||
responses: { '200': { description: 状态 PENDING_DISPATCH } }
|
||||
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/dispatch:
|
||||
parameters: *truckOrderTarget
|
||||
post:
|
||||
summary: 确认本土卡车已派车
|
||||
description: 权限 platform:overseas-business:truck:status;本土卡车单自身履约,不创建海外仓排车单、出库单或派送单。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCommand' } } } }
|
||||
responses: { '200': { description: 状态 DISPATCHED } }
|
||||
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/start-transit:
|
||||
parameters: *truckOrderTarget
|
||||
post:
|
||||
summary: 确认本土卡车开始运输
|
||||
description: 权限 platform:overseas-business:truck:status;仅 DISPATCHED 可进入 IN_TRANSIT。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCommand' } } } }
|
||||
responses: { '200': { description: 状态 IN_TRANSIT } }
|
||||
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/complete:
|
||||
parameters: *truckOrderTarget
|
||||
post:
|
||||
summary: 完成本土卡车订单
|
||||
description: 权限 platform:overseas-business:truck:status;仅 IN_TRANSIT 可进入不可逆 COMPLETED。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCommand' } } } }
|
||||
responses: { '200': { description: 状态 COMPLETED } }
|
||||
/api/platform/v1/last-leg/local-looseboard-orders:
|
||||
get:
|
||||
summary: 分页查询本土散板订单
|
||||
@@ -373,12 +463,12 @@ paths:
|
||||
/api/platform/v1/last-leg/local-looseboard-orders/{orderNo}/status:
|
||||
patch:
|
||||
summary: 推进或取消本土散板订单
|
||||
description: 权限 platform:overseas-business:looseboard:status;只允许 PENDING_ACCEPTANCE→DISPATCHED→IN_TRANSIT→COMPLETED,未完成节点按规则取消。
|
||||
description: 权限 platform:overseas-business:looseboard:status;只允许 PENDING_ACCEPTANCE→DISPATCHED→IN_TRANSIT→COMPLETED,另建订单可进入 CANCELLED;幂等键异请求、旧 version、非法跳转或终态反向返回 A0409。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: orderNo, in: path, required: true, schema: { type: string } }
|
||||
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [version, status], properties: { version: { type: integer, format: int64, minimum: 0 }, status: { $ref: '#/components/schemas/LooseboardStatus' }, remark: { type: string, nullable: true, maxLength: 1000 } } } } } }
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardStatusChange' } } } }
|
||||
responses: { '200': { description: 统一 ApiResponse 包装的散板订单详情 }, '409': { description: 非法状态迁移 } }
|
||||
/api/platform/v1/last-leg/relabel-applications:
|
||||
get:
|
||||
@@ -423,6 +513,101 @@ paths:
|
||||
responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } }
|
||||
components:
|
||||
schemas:
|
||||
PdaWarehouseScan:
|
||||
type: object
|
||||
required: [code]
|
||||
properties: { code: { type: string, minLength: 1, maxLength: 128 } }
|
||||
PdaInventoryItem:
|
||||
type: object
|
||||
required: [customerId, balanceId, positionId, inboundCode, palletId, palletCode, availablePackages, holdPackages, receivingPackages, status, version]
|
||||
properties:
|
||||
customerId: { type: string }
|
||||
customerName: { type: string, nullable: true }
|
||||
balanceId: { type: integer, format: int64 }
|
||||
positionId: { type: integer, format: int64 }
|
||||
skuCode: { type: string, nullable: true }
|
||||
productName: { type: string, nullable: true }
|
||||
inboundCode: { type: string }
|
||||
cartonMark: { type: string, nullable: true }
|
||||
palletId: { type: integer, format: int64 }
|
||||
palletCode: { type: string }
|
||||
locationId: { type: integer, format: int64, nullable: true }
|
||||
locationCode: { type: string, nullable: true }
|
||||
availablePackages: { type: integer, format: int64, minimum: 0 }
|
||||
holdPackages: { type: integer, format: int64, minimum: 0 }
|
||||
receivingPackages: { type: integer, format: int64, minimum: 0 }
|
||||
status: { type: string }
|
||||
version: { type: integer, format: int64, minimum: 0 }
|
||||
PdaInventoryScan:
|
||||
type: object
|
||||
required: [queryCode, items]
|
||||
properties:
|
||||
queryCode: { type: string }
|
||||
items: { type: array, items: { $ref: '#/components/schemas/PdaInventoryItem' } }
|
||||
PdaLocation:
|
||||
type: object
|
||||
required: [locationId, locationCode, locationType, status, occupiedPackages, inventoryPositionCount]
|
||||
properties:
|
||||
locationId: { type: integer, format: int64 }
|
||||
locationCode: { type: string }
|
||||
locationType: { type: string }
|
||||
maxLoadKg: { type: number, nullable: true }
|
||||
status: { type: string }
|
||||
occupiedPackages: { type: integer, format: int64, minimum: 0 }
|
||||
inventoryPositionCount: { type: integer, minimum: 0 }
|
||||
PdaPallet:
|
||||
type: object
|
||||
required: [palletId, palletCode, status, version, locationId, locationCode, occupiedPackages, inventoryPositionCount, inventory]
|
||||
properties:
|
||||
palletId: { type: integer, format: int64 }
|
||||
palletCode: { type: string }
|
||||
status: { type: string }
|
||||
version: { type: integer, minimum: 0 }
|
||||
locationId: { type: integer, format: int64 }
|
||||
locationCode: { type: string }
|
||||
occupiedPackages: { type: integer, format: int64, minimum: 0 }
|
||||
inventoryPositionCount: { type: integer, minimum: 0 }
|
||||
inventory: { type: array, items: { $ref: '#/components/schemas/PdaInventoryItem' } }
|
||||
PdaOperation:
|
||||
type: object
|
||||
required: [operationId, operationType, businessKey, status, occurredAt]
|
||||
properties:
|
||||
operationId: { type: integer, format: int64 }
|
||||
operationType: { type: string }
|
||||
businessKey: { type: string }
|
||||
status: { type: string }
|
||||
summary: { type: string, nullable: true }
|
||||
customerId: { type: string, nullable: true }
|
||||
occurredAt: { type: string, format: date-time }
|
||||
PdaInventoryScanApiResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/PdaInventoryScan' } }
|
||||
PdaLocationApiResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/PdaLocation' } }
|
||||
PdaPalletApiResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/PdaPallet' } }
|
||||
PdaOperationPageApiResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
required: [records, page, size, total, pages]
|
||||
properties:
|
||||
records: { type: array, items: { $ref: '#/components/schemas/PdaOperation' } }
|
||||
page: { type: integer }
|
||||
size: { type: integer }
|
||||
total: { type: integer, format: int64 }
|
||||
pages: { type: integer, format: int64 }
|
||||
ReceivingAction:
|
||||
type: object
|
||||
required: [idempotencyKey]
|
||||
@@ -765,6 +950,14 @@ components:
|
||||
status: { $ref: '#/components/schemas/RelabelStatus' }
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
ProcurementStatus: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] }
|
||||
ProcurementReview:
|
||||
type: object
|
||||
required: [version, decision, idempotencyKey]
|
||||
properties:
|
||||
version: { type: integer, format: int64, minimum: 0 }
|
||||
decision: { type: string, enum: [APPROVE, REJECT] }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
ProcurementCreate:
|
||||
type: object
|
||||
required: [purchaseCategory, productName, purchaseQuantity, contactName, contactPhone, idempotencyKey]
|
||||
@@ -812,6 +1005,49 @@ components:
|
||||
code: { type: string, enum: ['200'] }
|
||||
data: { type: array, items: { type: object, properties: { warehouseId: { type: string }, warehouseCode: { type: string }, warehouseName: { type: string }, countryCode: { type: string } } } }
|
||||
TruckStatus: { type: string, enum: [PENDING_REVIEW, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED] }
|
||||
TruckReview:
|
||||
type: object
|
||||
required: [version, decision, idempotencyKey]
|
||||
properties:
|
||||
version: { type: integer, format: int64, minimum: 0 }
|
||||
decision: { type: string, enum: [APPROVE, REJECT] }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
TruckCancel:
|
||||
type: object
|
||||
required: [version, status, idempotencyKey]
|
||||
properties:
|
||||
version: { type: integer, format: int64, minimum: 0 }
|
||||
status: { type: string, enum: [CANCELLED] }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
TruckReceivePallets:
|
||||
type: object
|
||||
required: [version, actualPalletCount, idempotencyKey]
|
||||
properties:
|
||||
version: { type: integer, format: int64, minimum: 0 }
|
||||
actualPalletCount: { type: integer, minimum: 1 }
|
||||
packageCount: { type: integer, nullable: true, minimum: 0 }
|
||||
palletPackageCounts: { type: array, maxItems: 500, items: { type: integer, minimum: 0 } }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
TruckReviewPalletOverage:
|
||||
allOf: [{ $ref: '#/components/schemas/TruckReview' }]
|
||||
TruckAllocatePallets:
|
||||
type: object
|
||||
required: [version, palletIds, idempotencyKey]
|
||||
properties:
|
||||
version: { type: integer, format: int64, minimum: 0 }
|
||||
palletIds: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: integer, format: int64, minimum: 1 } }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
TruckCommand:
|
||||
type: object
|
||||
required: [version, idempotencyKey]
|
||||
properties:
|
||||
version: { type: integer, format: int64, minimum: 0 }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
TruckCreate:
|
||||
type: object
|
||||
required: [origin, destination, palletCount, totalWeightKg, totalVolumeCbm, contactName, contactInfo, requestedDate, idempotencyKey]
|
||||
@@ -855,6 +1091,14 @@ components:
|
||||
requestedDate: { type: string, format: date }
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
LooseboardStatus: { type: string, enum: [PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] }
|
||||
LooseboardStatusChange:
|
||||
type: object
|
||||
required: [version, status, idempotencyKey]
|
||||
properties:
|
||||
version: { type: integer, format: int64, minimum: 0 }
|
||||
status: { $ref: '#/components/schemas/LooseboardStatus' }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
LooseboardCreate:
|
||||
type: object
|
||||
required: [transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey]
|
||||
|
||||
Reference in New Issue
Block a user