This commit is contained in:
2026-09-15 13:36:58 +08:00
parent 1bb69698de
commit f9649c44e9
44 changed files with 647 additions and 63 deletions
@@ -14,6 +14,7 @@ public class LastlegRestClientProperties {
private ClientTimeouts procurementFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
private ClientTimeouts warehouseResource = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5));
private ClientTimeouts palletPhysical = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5));
private ClientTimeouts domesticLoadingEvidence = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
public ClientTimeouts getUnloadPhotoFile() { return unloadPhotoFile; }
public void setUnloadPhotoFile(ClientTimeouts value) { unloadPhotoFile = required(value, "unload-photo-file"); }
@@ -27,6 +28,8 @@ public class LastlegRestClientProperties {
public void setWarehouseResource(ClientTimeouts value) { warehouseResource = required(value, "warehouse-resource"); }
public ClientTimeouts getPalletPhysical() { return palletPhysical; }
public void setPalletPhysical(ClientTimeouts value) { palletPhysical = required(value, "pallet-physical"); }
public ClientTimeouts getDomesticLoadingEvidence() { return domesticLoadingEvidence; }
public void setDomesticLoadingEvidence(ClientTimeouts value) { domesticLoadingEvidence = required(value, "domestic-loading-evidence"); }
private static ClientTimeouts required(ClientTimeouts value, String client) {
if (value == null) throw new IllegalArgumentException(client + " HTTP timeouts are required");
@@ -5,13 +5,11 @@ import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
public record ManualInboundPlanRequest(
@NotBlank @Size(max = 128) String customerId,
@NotNull Long orderId,
@NotNull @Min(0) Integer expectedOrderVersion,
@NotNull LocalDate plannedArrivalDate,
@NotNull @Min(1) @Max(999) Integer palletCount,
@NotBlank @Size(max = 32) String palletType,
@NotNull Boolean assigned,
@@ -75,6 +75,11 @@ public interface InboundPlanOrderMapper {
String findSourceContainerBookingId(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
@Select("SELECT estimated_warehouse_at FROM ll_order " +
"WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id")
List<LocalDateTime> containerEstimatedWarehouseTimes(
@Param("sourceContainerBookingId") String sourceContainerBookingId);
@Select("SELECT order_type FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
String findOrderType(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
@@ -91,6 +91,18 @@ public class ReceivingContainerController {
.contentLength(download.content().length).body(download.content());
}
@GetMapping("/current/domestic-loading-photos/{evidenceType}/content")
public ResponseEntity<byte[]> downloadDomesticLoadingPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String evidenceType) {
PdaWarehouseIdentity identity = currentIdentity(user);
var download = service.downloadDomesticLoadingPhoto(identity.operatorKey(), evidenceType);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.inline().filename(download.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType(download.contentType()))
.contentLength(download.content().length).body(download.content());
}
@PostMapping("/current/sign")
public ApiResponse<ReceivingContainerResponses.Session> sign(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.Sign request) {
@@ -32,7 +32,8 @@ public final class ReceivingContainerResponses {
public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseCode,
String overseasWarehouseName,
String status, String operatorName, int version,
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
List<SignedPhoto> photos, List<DomesticLoadingPhoto> domesticLoadingPhotos,
LocalDateTime preparedAt, LocalDateTime signedAt,
LocalDateTime receivingAt, LocalDateTime completedAt,
int totalPackages, int receivedPackages, int pendingPackages,
boolean hasDifference, List<Inbound> inbounds) {
@@ -42,7 +43,7 @@ public final class ReceivingContainerResponses {
LocalDateTime completedAt, int totalPackages, int receivedPackages,
int pendingPackages, List<Inbound> inbounds) {
this(containerNo, overseasWarehouseId, null, overseasWarehouseName, status, operatorName,
version, photos, preparedAt, signedAt, receivingAt, completedAt, totalPackages,
version, photos, List.of(), preparedAt, signedAt, receivingAt, completedAt, totalPackages,
receivedPackages, pendingPackages, false, inbounds);
}
@@ -52,13 +53,15 @@ public final class ReceivingContainerResponses {
LocalDateTime receivingAt, LocalDateTime completedAt, int totalPackages,
int receivedPackages, int pendingPackages, List<Inbound> inbounds) {
this(containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName,
status, operatorName, version, photos, preparedAt, signedAt, receivingAt,
status, operatorName, version, photos, List.of(), preparedAt, signedAt, receivingAt,
completedAt, totalPackages, receivedPackages, pendingPackages, false, inbounds);
}
}
public record SignedPhoto(ReceivingPhotoRole role, String fileId, String downloadPath) { }
public record DomesticLoadingPhoto(String evidenceType, String label, String downloadPath) { }
public record Inbound(String inboundCode, String destinationType,
String overseasWarehouseId, String overseasWarehouseName,
String destinationWarehouseId,
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.inboundplan.pda.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("ll_container_loading_evidence")
public class ContainerLoadingEvidence {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String sourceContainerBookingId;
private String evidenceType;
private Long sourceFileId;
private String fileName;
private LocalDateTime createdAt;
}
@@ -0,0 +1,7 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
public interface DomesticLoadingEvidenceGateway {
DownloadedFile download(long containerBookingId, long fileId);
record DownloadedFile(byte[] content, String fileName, String contentType) { }
}
@@ -0,0 +1,55 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
@Component
public class HttpDomesticLoadingEvidenceGateway implements DomesticLoadingEvidenceGateway {
@Resource private RestClient.Builder restClientBuilder;
@Resource private LastlegRestClientFactory restClientFactory;
@Resource private LastlegRestClientProperties restClientProperties;
@Value("${baiyue.clients.domestic-file.url:http://127.0.0.1:9205}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client;
@PostConstruct
void initializeClient() {
client = restClientFactory.build(restClientBuilder, baseUrl,
restClientProperties.getDomesticLoadingEvidence());
}
@Override
public DownloadedFile download(long containerBookingId, long fileId) {
ResponseEntity<byte[]> response = client.get()
.uri("/internal/file/v1/loading-evidence/{containerBookingId}/{fileId}/content",
containerBookingId, fileId)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (request, result) -> {
throw new ResponseStatusException(result.getStatusCode().value() == 404
? HttpStatus.NOT_FOUND : HttpStatus.BAD_REQUEST, "国内装柜照片不可用");
})
.onStatus(HttpStatusCode::is5xxServerError, (request, result) -> {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "国内装柜照片暂时无法加载");
}).toEntity(byte[].class);
byte[] content = response.getBody() == null ? new byte[0] : response.getBody();
String disposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
String fileName = null;
try { fileName = disposition == null ? null : ContentDisposition.parse(disposition).getFilename(); }
catch (IllegalArgumentException ignored) { }
String contentType = response.getHeaders().getContentType() == null
? MediaType.APPLICATION_OCTET_STREAM_VALUE : response.getHeaders().getContentType().toString();
return new DownloadedFile(content, fileName == null ? fileId + ".bin" : fileName, contentType);
}
}
@@ -0,0 +1,18 @@
package com.baiyue.lastleg.inboundplan.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ContainerLoadingEvidenceMapper extends BaseMapper<ContainerLoadingEvidence> {
@Select("SELECT * FROM ll_container_loading_evidence WHERE source_container_booking_id=#{bookingId} ORDER BY CASE evidence_type WHEN 'PHOTO_STEP_1' THEN 1 WHEN 'PHOTO_STEP_2' THEN 2 WHEN 'PHOTO_STEP_3' THEN 3 WHEN 'PHOTO_STEP_4' THEN 4 ELSE 5 END")
List<ContainerLoadingEvidence> listByContainer(@Param("bookingId") String bookingId);
@Select("SELECT * FROM ll_container_loading_evidence WHERE source_container_booking_id=#{bookingId} AND evidence_type=#{type} LIMIT 1")
ContainerLoadingEvidence find(@Param("bookingId") String bookingId, @Param("type") String type);
}
@@ -23,11 +23,13 @@ import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.DomesticLoadingEvidenceGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingFbaPalletCountMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
@@ -67,6 +69,8 @@ public class ReceivingContainerService {
private static final String PLATFORM_SCOPE = "0";
private static final String PHOTO_DOWNLOAD_PATH =
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download";
private static final String DOMESTIC_PHOTO_DOWNLOAD_PATH =
"/api/pda/v1/last-leg/receiving-containers/current/domestic-loading-photos/{type}/content";
private static final String PALLET_MERGE_HINT =
"该入库计划可选择合托:合托时扫描同目的仓已使用的托盘码,独立收货时扫描新托盘码;不强制合托";
@@ -86,6 +90,8 @@ public class ReceivingContainerService {
@Resource private DropshipOrderProfileMapper dropshipProfiles;
@Resource private WarehouseResourceGateway warehouse;
@Resource private UnloadPhotoFileGateway unloadPhotos;
@Resource private DomesticLoadingEvidenceGateway domesticEvidenceFiles;
@Resource private ContainerLoadingEvidenceMapper domesticEvidence;
@Resource private InboundPdaWorkflowService workflow;
@Resource private ObjectMapper sourceObjectMapper;
@@ -247,6 +253,19 @@ public class ReceivingContainerService {
return unloadPhotos.download(PLATFORM_SCOPE, session.getId(), parsedFileId);
}
public DomesticLoadingEvidenceGateway.DownloadedFile downloadDomesticLoadingPhoto(
String operatorKey, String evidenceType) {
ReceivingContainerSession session = requireCurrent(operatorKey, false);
String type = requiredEvidenceType(evidenceType);
var evidence = domesticEvidence.find(session.getSourceContainerBookingId(), type);
if (evidence == null) throw notFound("当前柜没有该国内装柜照片");
long bookingId;
try { bookingId = Long.parseLong(session.getSourceContainerBookingId()); }
catch (NumberFormatException exception) { throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE, "当前柜国内照片来源无效", exception); }
return domesticEvidenceFiles.download(bookingId, evidence.getSourceFileId());
}
public void requireCurrentWarehouse(String operatorKey, long overseasWarehouseId) {
requireSessionWarehouse(sessions.findActiveByOperator(activeOperatorKey(operatorKey)), overseasWarehouseId);
}
@@ -983,11 +1002,41 @@ public class ReceivingContainerService {
int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum();
return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(),
session.getOverseasWarehouseCode(), session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(),
value(session.getVersion()), signPhotos(session), session.getPreparedAt(), session.getSignedAt(),
value(session.getVersion()), signPhotos(session), domesticLoadingPhotos(session),
session.getPreparedAt(), session.getSignedAt(),
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received),
hasFbaOverReceipt(session, containerPlans), inbounds);
}
private List<ReceivingContainerResponses.DomesticLoadingPhoto> domesticLoadingPhotos(
ReceivingContainerSession session) {
String bookingId = trim(session.getSourceContainerBookingId());
if (bookingId == null) return List.of();
return domesticEvidence.listByContainer(bookingId).stream()
.map(value -> new ReceivingContainerResponses.DomesticLoadingPhoto(value.getEvidenceType(),
evidenceLabel(value.getEvidenceType()), DOMESTIC_PHOTO_DOWNLOAD_PATH.replace("{type}", value.getEvidenceType())))
.toList();
}
private static String requiredEvidenceType(String value) {
String type = trim(value);
if (type == null || !Set.of("PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL").contains(type)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "不支持的国内装柜照片类型");
}
return type;
}
private static String evidenceLabel(String type) {
return switch (type) {
case "PHOTO_STEP_1" -> "空仓";
case "PHOTO_STEP_2" -> "半仓";
case "PHOTO_STEP_3" -> "满仓";
case "PHOTO_STEP_4" -> "锁仓";
case "SEAL" -> "带锁码";
default -> type;
};
}
private void savePhysicalFbaCount(long sessionId, String groupKey, String palletCode, int actualPackages,
ReceivingFbaPalletCount existing) {
LocalDateTime now = LocalDateTime.now();
@@ -38,6 +38,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@@ -103,6 +104,8 @@ public class InboundPlanService {
}
String containerWarehouseId = requireSingleWarehouseId(containerFacts.stream()
.map(InboundPlanOrderMapper.ContainerWarehouseRef::overseasWarehouseId).toList());
LocalDate plannedArrivalDate = requiredContainerPlannedArrivalDate(
containerFacts.get(0).sourceContainerBookingId());
List<SourceOrderSnapshot> snapshots = orderMapper.lockOrder(customerId, request.orderId());
if (snapshots == null || snapshots.size() != 1) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在");
@@ -152,7 +155,7 @@ public class InboundPlanService {
"人工计划托数不能少于柜级预览建议托数 " + minimumPalletCount);
}
InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), null,
request, receivingWarehouse);
request, receivingWarehouse, plannedArrivalDate);
LastlegOrderCommandRecord command = new LastlegOrderCommandRecord();
command.setId(IdWorker.getId()); command.setCustomerId(customerId); command.setOrderId(request.orderId());
command.setAction("MANUAL_PLAN"); command.setOperatorId(operatorId); command.setIdempotencyKey(key);
@@ -426,21 +429,25 @@ public class InboundPlanService {
return detail(customerId, existing);
}
validateOrders(orders);
return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, null, null);
}
private InboundPlanResponse createPlan(String customerId, Long selectedOrderId,
List<SourceOrderSnapshot> orders, List<String> sourcePalletNos,
List<WholePalletInput> wholePallets, ManualInboundPlanRequest manual,
WarehouseResourceGateway.WarehouseReference receivingWarehouse) {
return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, manual,
receivingWarehouse, null, Map.of());
String sourceContainerBookingId = trimToNull(orderMapper.findSourceContainerBookingId(customerId, selectedOrderId));
return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, null, null,
requiredContainerPlannedArrivalDate(sourceContainerBookingId));
}
private InboundPlanResponse createPlan(String customerId, Long selectedOrderId,
List<SourceOrderSnapshot> orders, List<String> sourcePalletNos,
List<WholePalletInput> wholePallets, ManualInboundPlanRequest manual,
WarehouseResourceGateway.WarehouseReference receivingWarehouse,
LocalDate plannedArrivalDate) {
return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, manual,
receivingWarehouse, plannedArrivalDate, null, Map.of());
}
private InboundPlanResponse createPlan(String customerId, Long selectedOrderId,
List<SourceOrderSnapshot> orders, List<String> sourcePalletNos,
List<WholePalletInput> wholePallets, ManualInboundPlanRequest manual,
WarehouseResourceGateway.WarehouseReference receivingWarehouse,
LocalDate plannedArrivalDate,
PreparedPlan preparedPlan,
Map<Integer, WarehouseResourceGateway.PalletBatchItem> reservedPallets) {
String containerNo = requiredContainerNo(orders.get(0).containerNo());
@@ -463,7 +470,7 @@ public class InboundPlanService {
throw new ResponseStatusException(HttpStatus.CONFLICT, "接柜仓ID格式无效", exception);
}
plan.setStatus("PENDING_ASSIGNMENT");
plan.setPlannedArrivalDate(manual == null ? null : manual.plannedArrivalDate());
plan.setPlannedArrivalDate(plannedArrivalDate);
plan.setPalletType(manual == null ? null : manual.palletType().trim().toUpperCase());
plan.setAssignedFlag(manual != null && manual.assigned());
plan.setMergeRequired(manual != null && manual.mergeRequired());
@@ -550,6 +557,7 @@ public class InboundPlanService {
if (bookingId == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订 ID 不能为空");
List<InboundPlanOrderMapper.ContainerOrderRef> refs = orderMapper.lockContainerOrders(bookingId);
if (refs.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜没有尾程订单");
LocalDate plannedArrivalDate = requiredContainerPlannedArrivalDate(bookingId);
if (snapshotVersion != null) {
ContainerInboundPlanPreviewResponse current = previewContainer(bookingId);
if (current.snapshotVersion() != snapshotVersion || !Objects.equals(current.snapshotHash(), snapshotHash)
@@ -639,7 +647,7 @@ public class InboundPlanService {
.map(value -> value.reservation().palletCode()).toList();
}
created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), effectivePalletNos,
wholePallets.get(pendingIndex), null, null, prepared, reserved));
wholePallets.get(pendingIndex), null, null, plannedArrivalDate, prepared, reserved));
}
for (int index = 0; index < refs.size(); index++) {
InboundPlan existing = existingPlans.get(index);
@@ -752,6 +760,22 @@ public class InboundPlanService {
}
}
private LocalDate requiredContainerPlannedArrivalDate(String sourceContainerBookingId) {
String bookingId = trimToNull(sourceContainerBookingId);
if (bookingId == null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单未绑定来源柜,无法取得预计到仓时间");
}
List<LocalDateTime> values = orderMapper.containerEstimatedWarehouseTimes(bookingId);
if (values == null || values.isEmpty() || values.stream().anyMatch(Objects::isNull)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜预计到仓时间缺失,请先补齐柜级时间");
}
LocalDateTime expected = values.get(0);
if (values.stream().anyMatch(value -> !Objects.equals(expected, value))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜预计到仓时间不一致,请先修正柜级时间");
}
return expected.toLocalDate();
}
private void requirePlanContainer(InboundPlan plan, String containerNo) {
String frozen = requiredContainerNo(plan.getContainerNo());
if (!Objects.equals(containerNo, frozen)) {
@@ -7,6 +7,8 @@ import com.baiyue.lastleg.dropship.dto.DropshipRequests;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence;
import com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
@@ -46,12 +48,13 @@ public class LoadingCompletedConsumer {
@Resource private InventoryCommandService inventory;
@Resource private ContainerShippingGateMapper shippingGates;
@Resource private LastlegSourcePalletMapper sourcePallets;
@Resource private ContainerLoadingEvidenceMapper loadingEvidence;
@Transactional(isolation = Isolation.READ_COMMITTED)
public int consume(EventEnvelope envelope) {
envelope.requireValid();
if (!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType())
|| (envelope.eventVersion() != 1 && envelope.eventVersion() != 2))
|| (envelope.eventVersion() != 1 && envelope.eventVersion() != 2 && envelope.eventVersion() != 3))
throw new IllegalArgumentException("不支持的尾程交接事件");
if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS)
throw new IllegalArgumentException("尾程交接事件区域无效");
@@ -64,7 +67,7 @@ public class LoadingCompletedConsumer {
String sourceContainerBookingId = sourceContainerBookingId(envelope, payload);
if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单");
OwnerSnapshot owner = requireOwnerSnapshot(payload.orders(), envelope.tenantId());
if (envelope.eventVersion() == 2) validateCartonSkuMappings(payload, envelope.payload());
if (envelope.eventVersion() >= 2) validateCartonSkuMappings(payload, envelope.payload());
freezeContainerIdentity(envelope, sourceContainerBookingId, containerNo);
LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId());
if(existing!=null) {
@@ -85,6 +88,8 @@ public class LoadingCompletedConsumer {
return countOrders(envelope,containerNo);
}
projectLoadingEvidence(envelope, sourceContainerBookingId);
// The loading-plan and container-shipping events are independent aggregate streams.
// Seeding the shipping version from the loading-plan version makes a valid first node
// (aggregateVersion=0) look stale whenever the loading plan has a higher version.
@@ -100,6 +105,45 @@ public class LoadingCompletedConsumer {
return payload.orders().size();
}
private void projectLoadingEvidence(EventEnvelope envelope, String sourceContainerBookingId) {
if (envelope.eventVersion() < 3) return;
JsonNode values = envelope.payload().path("loadingEvidence");
if (!values.isArray() || values.size() != 5) {
throw new IllegalArgumentException("V3 尾程交接事件必须包含五类国内装柜照片");
}
Set<String> expected = Set.of("PHOTO_STEP_1", "PHOTO_STEP_2", "PHOTO_STEP_3", "PHOTO_STEP_4", "SEAL");
Set<String> actual = new HashSet<>();
for (JsonNode value : values) {
String type = value.path("evidenceType").asText("").trim();
long fileId = value.path("fileId").asLong(0);
String fileName = value.path("fileName").asText("").trim();
if (!expected.contains(type) || !actual.add(type) || fileId <= 0
|| fileName.isBlank() || fileName.length() > 255) {
throw new IllegalArgumentException("V3 尾程交接事件的国内装柜照片引用无效");
}
ContainerLoadingEvidence existing = loadingEvidence.find(sourceContainerBookingId, type);
if (existing != null) {
if (!Objects.equals(existing.getSourceFileId(), fileId)
|| !Objects.equals(existing.getFileName(), fileName)) {
throw new IllegalStateException("同一来源柜的国内装柜照片快照冲突");
}
continue;
}
ContainerLoadingEvidence row = new ContainerLoadingEvidence();
row.setId(IdWorker.getId());
row.setSourceContainerBookingId(sourceContainerBookingId);
row.setEvidenceType(type); row.setSourceFileId(fileId); row.setFileName(fileName);
row.setCreatedAt(LocalDateTime.now());
try { loadingEvidence.insert(row); }
catch (DuplicateKeyException duplicate) {
ContainerLoadingEvidence raced = loadingEvidence.find(sourceContainerBookingId, type);
if (raced == null || !Objects.equals(raced.getSourceFileId(), fileId)
|| !Objects.equals(raced.getFileName(), fileName)) throw duplicate;
}
}
if (!actual.equals(expected)) throw new IllegalArgumentException("V3 尾程交接事件的国内装柜照片类型不完整");
}
private FirstLegLoadingPlanCompletedV1 parsePayload(EventEnvelope envelope) {
try {
ObjectMapper compatibleMapper = objectMapper.copy()
@@ -134,7 +178,7 @@ public class LoadingCompletedConsumer {
}
private String sourceContainerBookingId(EventEnvelope envelope, FirstLegLoadingPlanCompletedV1 payload) {
String value = envelope.eventVersion() == 2
String value = envelope.eventVersion() >= 2
? envelope.payload().path("sourceContainerBookingId").asText(null)
: payload.containerBookingId();
if (value == null || value.isBlank() || value.trim().length() > 128) {
@@ -66,6 +66,9 @@ baiyue:
pallet-physical:
connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s}
domestic-loading-evidence:
connect-timeout: ${LASTLEG_DOMESTIC_LOADING_EVIDENCE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_DOMESTIC_LOADING_EVIDENCE_READ_TIMEOUT:30s}
clients:
print:
url: ${PRINT_SERVICE_URL:}
@@ -74,6 +77,8 @@ baiyue:
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
file:
url: ${FILE_SERVICE_URL:http://127.0.0.1:9205}
domestic-file:
url: ${DOMESTIC_FILE_SERVICE_URL:http://127.0.0.1:9205}
event-bridge:
url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}}
firstleg:
@@ -2542,3 +2542,15 @@ CREATE TABLE IF NOT EXISTS ll_return_inventory_operation (
id BIGINT NOT NULL COMMENT '退货库存PDA操作ID', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型,当前MERGE', result_carton_id BIGINT NULL COMMENT '合箱结果退货库存箱ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_operation_operator_key(operator_id,idempotency_key), KEY idx_ll_return_inventory_operation_result(result_carton_id)
) ENGINE=InnoDB COMMENT='退货库存PDA合箱幂等操作审计';
CREATE TABLE IF NOT EXISTS ll_container_loading_evidence (
id BIGINT NOT NULL COMMENT '海外整柜装柜照片投影ID',
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '国内稳定来源柜预订ID',
evidence_type VARCHAR(32) NOT NULL COMMENT 'PHOTO_STEP_1至PHOTO_STEP_4或SEAL',
source_file_id BIGINT NOT NULL COMMENT '国内File服务文件ID',
file_name VARCHAR(255) NOT NULL COMMENT '国内文件名快照',
created_at DATETIME(3) NOT NULL COMMENT '投影创建时间',
PRIMARY KEY(id),
UNIQUE KEY uk_ll_container_loading_evidence_type(source_container_booking_id,evidence_type),
UNIQUE KEY uk_ll_container_loading_evidence_file(source_container_booking_id,source_file_id)
) ENGINE=InnoDB COMMENT='海外拆柜只读国内装柜照片引用';
@@ -80,6 +80,22 @@ paths:
application/octet-stream: { schema: { type: string, format: binary } }
'403': { description: fileId 未绑定到当前操作员的当前柜会话三类签收照片 }
'404': { description: 当前柜会话或照片不存在 }
/api/pda/v1/last-leg/receiving-containers/current/domestic-loading-photos/{evidenceType}/content:
get:
summary: 查看当前柜国内装柜照片
description: 权限 lastleg:pda:operate;只按当前操作员已锁定的物理柜读取国内装柜完成快照,不返回客户或内部文件ID,加载失败不改变拆柜状态。
security: [{ bearerAuth: [] }]
parameters:
- { name: evidenceType, in: path, required: true, schema: { type: string, enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL] } }
responses:
'200':
description: 国内装柜照片字节流;禁止缓存。
content:
image/jpeg: { schema: { type: string, format: binary } }
image/png: { schema: { type: string, format: binary } }
application/octet-stream: { schema: { type: string, format: binary } }
'404': { description: 当前柜或指定类型照片不存在 }
'503': { description: 国内照片服务暂不可用 }
/api/pda/v1/last-leg/receiving-containers/current/sign:
post:
summary: 签收当前柜
@@ -434,7 +450,7 @@ paths:
/api/platform/v1/last-leg/inbound-plans/manual:
post:
summary: 平台按单票手工生成入库计划
description: 权限 platform:overseas-warehouse:inbound-order:create;按 customerId 与 orderId 锁定单票,校验订单版本、状态、暂扣状态、真实入仓码、客户归属及有效公共接柜仓。一次只生成当前票的独立计划,不改变同柜其他订单;命令原子且幂等。
description: 权限 platform:overseas-warehouse:inbound-order:create;按 customerId 与 orderId 锁定单票,校验订单版本、状态、暂扣状态、真实入仓码、客户归属及有效公共接柜仓。预计到仓日期由服务端锁定读取来源柜全部订单的 estimatedWarehouseAt,缺失或同柜不一致时拒绝生成,不接受调用方提交。一次只生成当前票的独立计划,不改变同柜其他订单;命令原子且幂等。
security: [{ bearerAuth: [] }]
parameters:
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
@@ -446,7 +462,7 @@ paths:
responses:
'200': { description: 统一 ApiResponse 包装的入库计划详情 }
'400': { description: 客户、入仓码、仓库主数据或请求字段无效 }
'409': { description: 订单版本、状态、暂扣状态、重复计划或幂等键冲突 }
'409': { description: 订单版本、状态、暂扣状态、柜级预计到仓时间、重复计划或幂等键冲突 }
/api/platform/v1/last-leg/inbound-plans/convert-container:
post:
summary: 按整柜批量生成剩余入库计划
@@ -1941,7 +1957,7 @@ components:
currentAccount: { type: boolean }
ReceivingSession:
type: object
required: [containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, version, photos, totalPackages, receivedPackages, pendingPackages, hasDifference, inbounds]
required: [containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, version, photos, domesticLoadingPhotos, totalPackages, receivedPackages, pendingPackages, hasDifference, inbounds]
properties:
containerNo: { type: string }
overseasWarehouseId: { type: string, pattern: '^\d+$', description: 平台预分配并与PDA Token匹配的海外接柜仓ID }
@@ -1951,6 +1967,7 @@ components:
operatorName: { type: string, nullable: true }
version: { type: integer }
photos: { type: array, maxItems: 3, items: { $ref: '#/components/schemas/ReceivingSignedSessionPhoto' } }
domesticLoadingPhotos: { type: array, maxItems: 5, items: { $ref: '#/components/schemas/DomesticLoadingPhoto' }, description: 当前物理柜国内装柜五类只读照片,不含客户信息与内部文件ID }
preparedAt: { type: string, format: date-time, nullable: true }
signedAt: { type: string, format: date-time, nullable: true }
receivingAt: { type: string, format: date-time, nullable: true }
@@ -1968,6 +1985,13 @@ components:
fileName: { type: string }
contentType: { type: string }
sizeBytes: { type: integer, format: int64 }
DomesticLoadingPhoto:
type: object
required: [evidenceType, label, downloadPath]
properties:
evidenceType: { type: string, enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL] }
label: { type: string, enum: [空仓, 半仓, 满仓, 锁仓, 带锁码] }
downloadPath: { type: string, description: 当前接柜会话鉴权下载路径 }
ReceivingCartonResolved:
allOf:
- { $ref: '#/components/schemas/ReceivingInbound' }
@@ -2606,13 +2630,12 @@ components:
items: { $ref: '#/components/schemas/LastlegOrderManualNode' }
ManualInboundPlanRequest:
type: object
description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。
required: [customerId, orderId, expectedOrderVersion, plannedArrivalDate, palletCount, palletType, assigned, mergeRequired, urgent]
description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。预计到仓日期由服务端读取来源柜冻结的 estimatedWarehouseArrivalAt,同柜所有计划保持一致,不接受人工提交。
required: [customerId, orderId, expectedOrderVersion, palletCount, palletType, assigned, mergeRequired, urgent]
properties:
customerId: { type: string, pattern: '^[1-9][0-9]*$' }
orderId: { type: integer, format: int64, minimum: 1 }
expectedOrderVersion: { type: integer, minimum: 0 }
plannedArrivalDate: { type: string, format: date }
palletCount: { type: integer, minimum: 1, maximum: 999 }
palletType: { type: string, minLength: 1, maxLength: 64 }
assigned: { type: boolean }
@@ -24,8 +24,10 @@ class TailPlanCommandOpenApiContractTest {
int manualSchemaEnd = api.indexOf("\n PageMetadata:", manualSchemaStart);
assertTrue(manualSchemaStart >= 0 && manualSchemaEnd > manualSchemaStart);
assertFalse(api.substring(manualSchemaStart, manualSchemaEnd).contains("inboundCode"));
assertFalse(api.substring(manualSchemaStart, manualSchemaEnd).contains("plannedArrivalDate"));
assertFalse(java.util.Arrays.stream(ManualInboundPlanRequest.class.getRecordComponents())
.anyMatch(component -> "inboundCode".equals(component.getName())));
.anyMatch(component -> java.util.Set.of("inboundCode", "plannedArrivalDate")
.contains(component.getName())));
assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/hold:"));
assertFalse(api.contains("/api/platform/v1/last-leg/orders/{id}/detained:"));
assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/manual-nodes:"));
@@ -20,7 +20,6 @@ import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Set;
import java.time.LocalDateTime;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
@@ -106,6 +105,6 @@ class InboundPlanControllerTest {
private static ManualInboundPlanRequest manualRequest() {
return new ManualInboundPlanRequest("193000000000200003", 9223372036854775807L, 0,
LocalDate.of(2026, 9, 8), 2, "STANDARD", false, true, true, "加急入仓");
2, "STANDARD", false, true, true, "加急入仓");
}
}
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.pda.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
import com.baiyue.lastleg.inboundplan.pda.gateway.DomesticLoadingEvidenceGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
@@ -90,6 +91,21 @@ class ReceivingContainerControllerTest {
verify(service).downloadCurrentPhoto("42", "9999");
}
@Test
void downloadDomesticLoadingPhotoDelegatesWithoutExposingTheSourceFileId() {
when(service.downloadDomesticLoadingPhoto("42", "PHOTO_STEP_1")).thenReturn(
new DomesticLoadingEvidenceGateway.DownloadedFile(
"domestic".getBytes(), "empty-container.jpg", MediaType.IMAGE_JPEG_VALUE));
ResponseEntity<byte[]> response = controller.downloadDomesticLoadingPhoto(picker(), "PHOTO_STEP_1");
assertThat(response.getStatusCodeValue()).isEqualTo(200);
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.IMAGE_JPEG);
assertThat(response.getHeaders().getCacheControl()).isEqualTo("no-store");
assertThat(response.getBody()).containsExactly("domestic".getBytes());
verify(service).downloadDomesticLoadingPhoto("42", "PHOTO_STEP_1");
}
@Test
void pickerAuthorityWithoutWarehouseBindingIsForbidden() {
AuthenticatedUser unbound = new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA",
@@ -18,11 +18,13 @@ import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.DomesticLoadingEvidenceGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingFbaPalletCountMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
@@ -71,6 +73,8 @@ class ReceivingContainerServiceBehaviorTest {
@Mock DropshipOrderProfileMapper dropshipProfiles;
@Mock WarehouseResourceGateway warehouse;
@Mock UnloadPhotoFileGateway unloadPhotos;
@Mock DomesticLoadingEvidenceGateway domesticEvidenceFiles;
@Mock ContainerLoadingEvidenceMapper domesticEvidence;
@Mock InboundPdaWorkflowService workflow;
private ReceivingContainerService service;
@@ -93,11 +97,14 @@ class ReceivingContainerServiceBehaviorTest {
ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles);
ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "unloadPhotos", unloadPhotos);
ReflectionTestUtils.setField(service, "domesticEvidenceFiles", domesticEvidenceFiles);
ReflectionTestUtils.setField(service, "domesticEvidence", domesticEvidence);
ReflectionTestUtils.setField(service, "workflow", workflow);
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules());
service.initializeObjectMapper();
org.mockito.Mockito.lenient().when(shippingGates.receivingAvailable(any(), any())).thenReturn(1);
org.mockito.Mockito.lenient().when(pallets.selectCount(any())).thenReturn(1L);
org.mockito.Mockito.lenient().when(domesticEvidence.listByContainer(any())).thenReturn(List.of());
}
@Test
@@ -904,6 +911,25 @@ class ReceivingContainerServiceBehaviorTest {
verifyNoInteractions(warehouse);
}
@Test
void domesticLoadingPhotoDownloadIsScopedToTheCurrentPhysicalContainer() {
ReceivingContainerSession current = session("CONT-A", "SIGNED");
current.setSourceContainerBookingId("91");
when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(current);
var evidence = new com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence();
evidence.setSourceContainerBookingId("91"); evidence.setEvidenceType("SEAL");
evidence.setSourceFileId(501L); evidence.setFileName("seal.jpg");
when(domesticEvidence.find("91", "SEAL")).thenReturn(evidence);
var downloaded = new DomesticLoadingEvidenceGateway.DownloadedFile(
new byte[]{1, 2}, "seal.jpg", "image/jpeg");
when(domesticEvidenceFiles.download(91L, 501L)).thenReturn(downloaded);
assertThat(service.downloadDomesticLoadingPhoto("7", "SEAL")).isSameAs(downloaded);
verify(domesticEvidenceFiles).download(91L, 501L);
assertThatThrownBy(() -> service.downloadDomesticLoadingPhoto("7", "UNKNOWN"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("400");
}
private void stubCurrent(String destinationType, String containerNo) {
InboundPlan plan = plan(containerNo);
InboundPlanLine line = line(destinationType);
@@ -409,7 +409,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
}
private ManualInboundPlanRequest manualRequest() {
return new ManualInboundPlanRequest("100", 11L, 0, LocalDate.of(2026, 9, 10),
return new ManualInboundPlanRequest("100", 11L, 0,
1, "WOOD", false, false, false, "自动生成");
}
@@ -93,6 +93,10 @@ class InboundPlanServiceTest {
org.mockito.Mockito.lenient().when(orderMapper.lockContainerWarehouseFacts(any(), any())).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerWarehouseRef(
"100", 2L, "BOOKING-1", "193000000000200003")));
org.mockito.Mockito.lenient().when(orderMapper.findSourceContainerBookingId(any(), anyLong()))
.thenReturn("BOOKING-1");
org.mockito.Mockito.lenient().when(orderMapper.containerEstimatedWarehouseTimes(any()))
.thenReturn(List.of(LocalDateTime.of(2026, 9, 15, 8, 0)));
}
@Test
@@ -108,6 +112,7 @@ class InboundPlanServiceTest {
ArgumentCaptor<InboundPlan> plan = ArgumentCaptor.forClass(InboundPlan.class);
verify(planMapper).insert(plan.capture());
assertEquals(2L, plan.getValue().getSourceOrderId());
assertEquals(LocalDate.of(2026, 9, 15), plan.getValue().getPlannedArrivalDate());
assertEquals(1, plan.getValue().getLineCount());
assertEquals("PENDING_ASSIGNMENT", plan.getValue().getStatus());
assertEquals(1, plan.getValue().getPalletCount());
@@ -149,7 +154,7 @@ class InboundPlanServiceTest {
when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(2), eq(true), eq(0), any()))
.thenReturn(1);
ManualInboundPlanRequest request = new ManualInboundPlanRequest("100", 2L, 0,
LocalDate.of(2026, 9, 15), 2, "STANDARD", true, true, true, "优先收货");
2, "STANDARD", true, true, true, "优先收货");
var result = service.convertManual(request, "manual-2", "900");
@@ -167,6 +172,27 @@ class InboundPlanServiceTest {
verify(orderMapper).lockContainerWarehouseFacts("100", 2L);
}
@Test
void manualPlanRejectsMissingOrInconsistentContainerArrivalTime() {
ManualInboundPlanRequest request = new ManualInboundPlanRequest("100", 2L, 0,
2, "STANDARD", true, true, true, "优先收货");
when(orderMapper.containerEstimatedWarehouseTimes("BOOKING-1")).thenReturn(List.of());
ResponseStatusException missing = assertThrows(ResponseStatusException.class,
() -> service.convertManual(request, "manual-missing-arrival", "900"));
assertEquals(409, missing.getStatusCode().value());
assertEquals("来源柜预计到仓时间缺失,请先补齐柜级时间", missing.getReason());
when(orderMapper.containerEstimatedWarehouseTimes("BOOKING-1")).thenReturn(List.of(
LocalDateTime.of(2026, 9, 15, 8, 0), LocalDateTime.of(2026, 9, 16, 8, 0)));
ResponseStatusException inconsistent = assertThrows(ResponseStatusException.class,
() -> service.convertManual(request, "manual-inconsistent-arrival", "900"));
assertEquals(409, inconsistent.getStatusCode().value());
assertEquals("来源柜预计到仓时间不一致,请先修正柜级时间", inconsistent.getReason());
verify(orderMapper, never()).lockOrder(any(), any());
verify(planMapper, never()).insert(any(InboundPlan.class));
}
@Test
void manualPlanMayExceedButCannotUndercutPreviewPalletCount() {
SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
@@ -175,7 +201,7 @@ class InboundPlanServiceTest {
null, null, "Amazon ACY2", 0, 0, false, 0);
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual(
new ManualInboundPlanRequest("100", 2L, 0, LocalDate.of(2026, 9, 15),
new ManualInboundPlanRequest("100", 2L, 0,
2, "WOOD", false, false, false, null), "manual-below-preview", "900"));
assertEquals(400, error.getStatusCode().value());
@@ -192,7 +218,7 @@ class InboundPlanServiceTest {
new DropshipPalletSnapshot("DP-260914-0001", 10L, List.of())));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual(
new ManualInboundPlanRequest("100", 2L, 0, LocalDate.of(2026, 9, 15),
new ManualInboundPlanRequest("100", 2L, 0,
2, "WOOD", false, false, false, null), "dropship-pallet-mismatch", "900"));
assertEquals(400, error.getStatusCode().value());
@@ -212,7 +238,7 @@ class InboundPlanServiceTest {
.thenReturn(1);
var result = service.convertManual(new ManualInboundPlanRequest("100", 2L, 0,
LocalDate.of(2026, 9, 15), 1, "WOOD", false, false, false, null),
1, "WOOD", false, false, false, null),
"dropship-exact-pallet-count", "900");
assertEquals(1, result.palletCount());
@@ -266,7 +292,7 @@ class InboundPlanServiceTest {
.thenReturn(1);
var result = service.convertManual(new ManualInboundPlanRequest("100", 2L, 0,
LocalDate.of(2026, 9, 15), 3, "STANDARD", true, false, false, null),
3, "STANDARD", true, false, false, null),
"manual-id-only", "900");
assertEquals("US-LAX-01", result.overseasWarehouseCode());
@@ -282,7 +308,7 @@ class InboundPlanServiceTest {
new InboundPlanOrderMapper.ContainerWarehouseRef("200", 3L, "BOOKING-1", "193000000000200004")));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual(
new ManualInboundPlanRequest("100", 2L, 0, LocalDate.of(2026, 9, 15),
new ManualInboundPlanRequest("100", 2L, 0,
1, "STANDARD", false, false, false, null), "mixed-warehouse", "900"));
assertEquals(409, error.getStatusCode().value());
@@ -300,7 +326,7 @@ class InboundPlanServiceTest {
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(held));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual(
new ManualInboundPlanRequest("100", 2L, 5, LocalDate.of(2026, 9, 15),
new ManualInboundPlanRequest("100", 2L, 5,
1, "STANDARD", false, false, false, null),
"manual-held", "900"));
@@ -3,8 +3,8 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
class LoadingCompletedConsumerTest {
private final Map<String,String> frozenContainerNos=new HashMap<>();
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); }
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper loadingEvidence; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); }
@Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); }
@Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(15,service.consume(fifteen)); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); }
@Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
@@ -241,6 +241,26 @@ class LoadingCompletedConsumerTest {
return new EventEnvelope(original.eventId(),original.eventType(),2,original.occurredAt(),original.sourceRegion(),original.targetRegion(),original.tenantId(),original.aggregateType(),original.aggregateId(),original.aggregateVersion(),2L,original.traceId(),original.correlationId(),original.causationId(),node,hash);
}
private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU6639870","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1),"100","Customer 100","FIRST_LEG",List.of()),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2),"100","Customer 100","FIRST_LEG",List.of()))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); }
@Test void projectsFiveDomesticLoadingPhotosOncePerPhysicalContainer(){
EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=(com.fasterxml.jackson.databind.node.ObjectNode)v2Payload(source);
var values=node.putArray("loadingEvidence"); int fileId=10;
for(String type:List.of("PHOTO_STEP_1","PHOTO_STEP_2","PHOTO_STEP_3","PHOTO_STEP_4","SEAL")) values.addObject().put("evidenceType",type).put("fileId",fileId++).put("fileName",type+".jpg");
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
EventEnvelope event=new EventEnvelope(source.eventId(),source.eventType(),3,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),3L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
assertEquals(2,service.consume(event));
ArgumentCaptor<com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence> captured=ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence.class);
verify(loadingEvidence,times(5)).insert(captured.capture());
assertEquals(Set.of("PHOTO_STEP_1","PHOTO_STEP_2","PHOTO_STEP_3","PHOTO_STEP_4","SEAL"),captured.getAllValues().stream().map(com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence::getEvidenceType).collect(java.util.stream.Collectors.toSet()));
assertTrue(captured.getAllValues().stream().allMatch(value->"book-1".equals(value.getSourceContainerBookingId())));
}
@Test void rejectsV3EventWithoutTheFiveDomesticLoadingPhotos(){
EventEnvelope source=event(); JsonNode node=v2Payload(source);
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
EventEnvelope event=new EventEnvelope(source.eventId(),source.eventType(),3,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),3L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event));
assertTrue(error.getMessage().contains("五类国内装柜照片"));
verify(loadingEvidence,never()).insert(any(com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence.class));
}
private JsonNode v2Payload(EventEnvelope source){ com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); node.set("sourceContainerBookingId",node.remove("containerBookingId")); return node; }
private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); for(JsonNode order:node.path("orders")) for(JsonNode pallet:order.path("pallets")) addManifest(pallet,order.path("packageCount").asInt()); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); }
private void addManifest(JsonNode pallet,int packageCount){
@@ -43,7 +43,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
class TaillegRealConsumerAcceptanceTest {
static final String CUSTOMER = "990022";
static final String MARKER = "REAL-TAIL-20260822-R3";
static final String EVENT_KEY = "FIRSTLEG_LOADING_COMPLETED:990022022:990022:2";
static final String EVENT_KEY = "FIRSTLEG_LOADING_COMPLETED:990022022:990022:3";
static final LocalDateTime RELABEL_DEADLINE = LocalDateTime.of(2026, 8, 29, 11, 1, 21, 628_000_000);
static final LocalDateTime TRANSFER_ETA = LocalDateTime.of(2026, 9, 1, 11, 1, 21, 674_000_000);
static final LocalDateTime RETURN_RELABEL_ETA = LocalDateTime.of(2026, 9, 1, 11, 1, 21, 756_000_000);
@@ -62,7 +62,7 @@ class TaillegRealConsumerAcceptanceTest {
InboundPlanService.class, InventoryCommandService.class, RelabelApplicationService.class,
WarehouseTransferService.class, LocalLooseboardService.class})
@MapperScan({"com.baiyue.lastleg.mapper", "com.baiyue.lastleg.dropship.mapper",
"com.baiyue.lastleg.inboundplan.mapper", "com.baiyue.lastleg.inventory.mapper",
"com.baiyue.lastleg.inboundplan.mapper", "com.baiyue.lastleg.inboundplan.pda.mapper", "com.baiyue.lastleg.inventory.mapper",
"com.baiyue.lastleg.inventory.command.mapper", "com.baiyue.lastleg.relabel.mapper",
"com.baiyue.lastleg.transfer.mapper", "com.baiyue.lastleg.looseboard.mapper"})
static class AcceptanceApplication {