优化
This commit is contained in:
+3
@@ -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");
|
||||
|
||||
-2
@@ -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,
|
||||
|
||||
+5
@@ -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);
|
||||
|
||||
+12
@@ -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) {
|
||||
|
||||
+6
-3
@@ -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,
|
||||
|
||||
+22
@@ -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;
|
||||
}
|
||||
+7
@@ -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) { }
|
||||
}
|
||||
+55
@@ -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);
|
||||
}
|
||||
}
|
||||
+18
@@ -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);
|
||||
}
|
||||
+50
-1
@@ -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();
|
||||
|
||||
+36
-12
@@ -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)) {
|
||||
|
||||
+47
-3
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user