feat: gate overseas receiving and harden phase4 workflows

This commit is contained in:
2026-08-25 10:31:04 +08:00
parent cb78bc6b41
commit 52b9fc382f
93 changed files with 2779 additions and 201 deletions
@@ -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")
@@ -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;
@@ -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,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) { }
}
@@ -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();
@@ -68,5 +68,6 @@ public final class LocalLooseboardRequests {
public record ChangeStatus(
@NotNull @Min(0) Long version,
@NotBlank @Size(max = 32) String status,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) { }
}
@@ -17,7 +17,7 @@ public final class LocalLooseboardResponses {
public record History(
String operationType, String fromStatus, String toStatus, String operatorId,
String changeDetail, String remark, LocalDateTime createdAt) { }
String idempotencyKey, String changeDetail, String remark, LocalDateTime createdAt) { }
public record Detail(
Summary summary, String originAddress, String destinationAddress, String remark,
@@ -17,6 +17,7 @@ public class LocalLooseboardHistory {
private String fromStatus;
private String toStatus;
private String operatorId;
private String idempotencyKey;
private String changeDetail;
private String remark;
private LocalDateTime createdAt;
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.looseboard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_local_looseboard_operation")
public class LocalLooseboardOperation {
@TableId(type = IdType.INPUT) private Long id;
private Long orderId;
private String customerId;
private String operationType;
private String idempotencyKey;
private String requestHash;
private Long resultVersion;
private String createdBy;
private LocalDateTime createdAt;
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.looseboard.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface LocalLooseboardOperationMapper extends BaseMapper<LocalLooseboardOperation> {
@Select("SELECT * FROM ll_local_looseboard_operation WHERE customer_id=#{customerId} AND order_id=#{orderId} AND idempotency_key=#{key} LIMIT 1")
LocalLooseboardOperation findKey(@Param("customerId") String customerId,
@Param("orderId") Long orderId,
@Param("key") String key);
}
@@ -12,8 +12,10 @@ import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOperationMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -51,6 +53,7 @@ public class LocalLooseboardService {
@Resource private LocalLooseboardOrderMapper orders;
@Resource private LocalLooseboardHistoryMapper histories;
@Resource private LocalLooseboardOperationMapper operations;
@Resource private LastlegOrderMapper lastlegOrders;
@Resource private LastlegOrderStatusHistoryMapper lastlegHistories;
@Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator;
@@ -88,7 +91,8 @@ public class LocalLooseboardService {
if (concurrent != null) return replay(customerId, concurrent, requestHash);
throw exception;
}
history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId, null, request.remark(), now);
history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId,
key, null, request.remark(), now);
lastlegHistory(customerId, lastlegOrder.getId(), null, lastlegOrder.getCurrentStatus(),
"Local looseboard order submitted", now);
return detail(customerId, order.getOrderNo());
@@ -139,7 +143,7 @@ public class LocalLooseboardService {
touch(order, now);
orders.updateById(order);
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
changeDetail(before, after), null, now);
null, changeDetail(before, after), null, now);
return response(order);
}
@@ -147,6 +151,15 @@ public class LocalLooseboardService {
public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.ChangeStatus request) {
LocalLooseboardOrder order = lock(customerId, orderNo);
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
LocalLooseboardOperation existing = operations.findKey(customerId, order.getId(), key);
if (existing != null) {
if (!"STATUS".equals(existing.getOperationType()) || !requestHash.equals(existing.getRequestHash())) {
throw conflict("Idempotency key was reused with a different looseboard status payload");
}
return response(order);
}
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
String next = norm(request.status());
if (!STATUSES.contains(next)) throw badRequest("Unsupported looseboard order status");
@@ -160,7 +173,13 @@ public class LocalLooseboardService {
touch(order, now);
orders.updateById(order);
syncLastlegOrder(order, next, now);
history(order, "STATUS", from, next, operatorId, null, request.remark(), now);
LocalLooseboardOperation operation = new LocalLooseboardOperation();
operation.setId(IdWorker.getId()); operation.setOrderId(order.getId());
operation.setCustomerId(customerId); operation.setOperationType("STATUS");
operation.setIdempotencyKey(key); operation.setRequestHash(requestHash);
operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId);
operation.setCreatedAt(now); operations.insert(operation);
history(order, "STATUS", from, next, operatorId, key, null, request.remark(), now);
return response(order);
}
@@ -204,7 +223,8 @@ public class LocalLooseboardService {
order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(),
histories.listOrder(order.getCustomerId(), order.getId()).stream()
.map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(),
value.getToStatus(), value.getOperatorId(), value.getChangeDetail(), value.getRemark(),
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
value.getChangeDetail(), value.getRemark(),
value.getCreatedAt())).toList());
}
@@ -218,7 +238,7 @@ public class LocalLooseboardService {
}
private void history(LocalLooseboardOrder order, String type, String from, String to, String operator,
String changeDetail, String remark, LocalDateTime now) {
String idempotencyKey, String changeDetail, String remark, LocalDateTime now) {
LocalLooseboardHistory history = new LocalLooseboardHistory();
history.setId(IdWorker.getId());
history.setOrderId(order.getId());
@@ -227,6 +247,7 @@ public class LocalLooseboardService {
history.setFromStatus(from);
history.setToStatus(to);
history.setOperatorId(operator);
history.setIdempotencyKey(trim(idempotencyKey));
history.setChangeDetail(changeDetail);
history.setRemark(trim(remark));
history.setCreatedAt(now);
@@ -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));
}
}
@@ -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," +
@@ -0,0 +1,101 @@
package com.baiyue.lastleg.pda.warehouse.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseRequests;
import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseResponses;
import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory;
import com.baiyue.lastleg.pda.warehouse.mapper.PdaWarehouseMapper;
import com.baiyue.lastleg.pda.warehouse.model.PdaInventoryProjection;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
@Service
public class PdaWarehouseService {
@Resource private PdaWarehouseMapper mapper;
@Resource private WarehouseResourceGateway warehouse;
public PdaWarehouseResponses.InventoryScan inventory(long warehouseId, PdaWarehouseRequests.Scan request) {
String code = code(request.code());
List<PdaWarehouseResponses.InventoryItem> items = items(mapper.scanInventory(String.valueOf(warehouseId), code));
if (items.isEmpty()) throw notFound("Inventory code was not found in the bound warehouse");
return new PdaWarehouseResponses.InventoryScan(code, items);
}
public PdaWarehouseResponses.Location location(long warehouseId, PdaWarehouseRequests.Scan request) {
WarehouseResourceGateway.WarehouseLocationReference location = warehouse.resolveLocation(warehouseId,
code(request.code()));
List<PdaWarehouseResponses.InventoryItem> inventory = items(mapper.inventoryByLocation(
String.valueOf(warehouseId), location.locationId(), location.locationCode(), "__NO_CARTON__"));
long packages = inventory.stream().mapToLong(this::packages).sum();
int positions = (int) inventory.stream().map(PdaWarehouseResponses.InventoryItem::positionId).distinct().count();
return new PdaWarehouseResponses.Location(location.locationId(), location.locationCode(),
location.locationType(), location.maxLoadKg(), location.status(), packages, positions);
}
public PdaWarehouseResponses.Pallet pallet(long warehouseId, PdaWarehouseRequests.Scan request) {
WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(code(request.code()));
if (pallet.warehouseId() != warehouseId) throw conflict("Pallet belongs to another overseas warehouse");
WarehouseResourceGateway.WarehouseLocationReference location = warehouse.resolveLocation(warehouseId,
pallet.locationId());
List<PdaWarehouseResponses.InventoryItem> inventory = items(mapper.inventoryByPallet(
String.valueOf(warehouseId), pallet.palletId(), "__NO_CARTON__"));
long packages = inventory.stream().mapToLong(this::packages).sum();
int positions = (int) inventory.stream().map(PdaWarehouseResponses.InventoryItem::positionId).distinct().count();
return new PdaWarehouseResponses.Pallet(pallet.palletId(), pallet.palletNo(), pallet.status(), pallet.version(),
location.locationId(), location.locationCode(), packages, positions, inventory);
}
public PageResult<PdaWarehouseResponses.Operation> history(long warehouseId, String operatorKey,
PdaWarehouseRequests.HistoryQuery query) {
long offset = (query.page() - 1) * query.size();
List<PdaWarehouseResponses.Operation> rows = mapper.history(warehouseId, operatorKey,
trim(query.operationType()), offset, query.size());
long total = mapper.countHistory(warehouseId, operatorKey, trim(query.operationType()));
return new PageResult<>(rows, query.page(), query.size(), total,
total == 0 ? 0 : (total + query.size() - 1) / query.size());
}
public void record(long warehouseId, String customerId, String operatorKey, String operationType,
String businessKey, String status, String idempotencyKey, String requestHash, String summary) {
PdaOperationHistory history = new PdaOperationHistory();
history.setId(IdWorker.getId()); history.setOverseasWarehouseId(warehouseId);
history.setCustomerId(trim(customerId)); history.setOperatorKey(operatorKey);
history.setOperationType(operationType); history.setBusinessKey(businessKey); history.setStatus(status);
history.setIdempotencyKey(trim(idempotencyKey)); history.setRequestHash(trim(requestHash));
history.setSummary(trim(summary)); history.setOccurredAt(LocalDateTime.now()); history.setCreatedAt(history.getOccurredAt());
mapper.insert(history);
}
private List<PdaWarehouseResponses.InventoryItem> items(List<PdaInventoryProjection> values) {
LinkedHashMap<String, PdaWarehouseResponses.InventoryItem> result = new LinkedHashMap<>();
for (PdaInventoryProjection value : values) {
String key = value.getPositionId() + ":" + text(value.getSkuCode()) + ":" + text(value.getCartonMark());
result.putIfAbsent(key, new PdaWarehouseResponses.InventoryItem(value.getCustomerId(), value.getCustomerName(),
number(value.getBalanceId()), number(value.getPositionId()), value.getSkuCode(),
first(value.getProductNameZh(), value.getProductNameEn()), value.getInboundCode(), value.getCartonMark(),
number(value.getPalletId()), value.getPalletCode(), value.getLocationId(), value.getLocationCode(),
number(value.getAvailablePackages()), number(value.getHoldPackages()), number(value.getReceivingPackages()),
value.getStatus(), number(value.getVersion())));
}
return List.copyOf(result.values());
}
private long packages(PdaWarehouseResponses.InventoryItem value) {
return value.availablePackages() + value.holdPackages() + value.receivingPackages();
}
private static String code(String value) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Scan code is required"); return result; }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String text(String value) { return value == null ? "" : value; }
private static String first(String first, String second) { String value = trim(first); return value == null ? trim(second) : value; }
private static long number(Long value) { return value == null ? 0 : value; }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -36,7 +36,11 @@ public final class OverseasProcurementRequests {
String productName, String status, String purchaseWarehouseId,
LocalDateTime createdFrom, LocalDateTime createdTo) {}
public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {}
public record Review(
@NotNull @Min(0) Long version,
@NotBlank String decision,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
public record Update(
@NotNull @Min(0) Long version,
@@ -22,7 +22,7 @@ public final class OverseasProcurementResponses {
public record History(
String operationType, String fromStatus, String toStatus, String operatorId,
String changeDetail, String remark,
String idempotencyKey, String changeDetail, String remark,
LocalDateTime createdAt) {}
public record Detail(
@@ -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;
@@ -17,6 +17,7 @@ public class OverseasProcurementStatusHistory {
private String fromStatus;
private String toStatus;
private String operatorId;
private String idempotencyKey;
private String changeDetail;
private String remark;
private LocalDateTime createdAt;
@@ -89,7 +89,8 @@ public class OverseasProcurementService {
if (concurrent != null) return replay(customerId, concurrent, requestHash);
throw exception;
}
history(order, "CREATE", null, PENDING_REVIEW, operatorId, null, request.remark(), now);
history(order, "CREATE", null, PENDING_REVIEW, operatorId,
key, null, request.remark(), now);
return detail(customerId, order.getOrderNo());
}
@@ -144,7 +145,8 @@ public class OverseasProcurementService {
order.getCreatedBy(), order.getReviewedBy(), order.getReviewRemark(), order.getReviewedAt(),
histories.listOrder(customerId, order.getId()).stream().map(value ->
new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(),
value.getToStatus(), value.getOperatorId(), value.getChangeDetail(), value.getRemark(),
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
value.getChangeDetail(), value.getRemark(),
value.getCreatedAt())).toList());
}
@@ -157,16 +159,27 @@ public class OverseasProcurementService {
default -> throw badRequest("Review decision must be APPROVE or REJECT");
};
OverseasProcurementOrder order = lock(customerId, orderNo);
if (next.equals(order.getStatus())) return detail(customerId, orderNo);
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
if (key.equals(order.getReviewIdempotencyKey())) {
if (!requestHash.equals(order.getReviewRequestHash())) {
throw conflict("Review idempotency key was reused with a different payload");
}
return detail(customerId, orderNo);
}
if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry");
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Overseas procurement order was already reviewed");
LocalDateTime now = LocalDateTime.now();
order.setStatus(next);
order.setReviewedBy(operatorId);
order.setReviewRemark(trim(request.remark()));
order.setReviewIdempotencyKey(key);
order.setReviewRequestHash(requestHash);
order.setReviewedAt(now);
touch(order, now);
orders.updateById(order);
history(order, "REVIEW", PENDING_REVIEW, next, operatorId, null, request.remark(), now);
history(order, "REVIEW", PENDING_REVIEW, next, operatorId,
key, null, request.remark(), now);
return detail(customerId, orderNo);
}
@@ -197,7 +210,7 @@ public class OverseasProcurementService {
touch(order, now);
orders.updateById(order);
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
changeDetail(before, after), null, now);
null, changeDetail(before, after), null, now);
return detail(customerId, orderNo);
}
@@ -224,7 +237,8 @@ public class OverseasProcurementService {
}
private void history(OverseasProcurementOrder order, String operationType, String from, String to,
String operator, String changeDetail, String remark, LocalDateTime now) {
String operator, String idempotencyKey, String changeDetail,
String remark, LocalDateTime now) {
OverseasProcurementStatusHistory history = new OverseasProcurementStatusHistory();
history.setId(IdWorker.getId());
history.setOrderId(order.getId());
@@ -233,6 +247,7 @@ public class OverseasProcurementService {
history.setFromStatus(from);
history.setToStatus(to);
history.setOperatorId(operator);
history.setIdempotencyKey(trim(idempotencyKey));
history.setChangeDetail(changeDetail);
history.setRemark(trim(remark));
history.setCreatedAt(now);
@@ -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++) {
@@ -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;
}
@@ -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);
}
@@ -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;
}
}
@@ -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]