Merge test into dev and resolve conflicts

This commit is contained in:
2026-09-14 11:02:37 +08:00
342 changed files with 8454 additions and 1591 deletions
@@ -22,5 +22,6 @@ public interface DomesticWarehouseDirectoryClient {
record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName,
String warehouseType, String countryCode, String status) { }
record Address(String stateProvince) { }
record Address(String addressType, String countryCode, String stateProvince, String city,
String district, String postalCode, String addressLine1, String addressLine2) { }
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.client;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
@FeignClient(name = "print-service", contextId = "lastlegPrintPdfClient",
url = "${baiyue.clients.print.url:}", path = "/internal/print/v1/pdfs")
public interface PrintPdfClient {
@PostMapping(value = "/dispatch-bol-documents", consumes = "application/json")
ResponseEntity<byte[]> generateDispatchBol(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
@RequestBody Request request);
record Request(String templateCode, PageSpec pageSpec, String downloadFileName, List<Document> documents) {}
record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {}
record Document(String shipFromName, String shipFromAddress, String carrierName, String isaNumber,
String appointmentTime, String trailerNumber, String sealNumber, String fbaWarehouseCode,
String shipToName, String shipToAddress, String dispatchNo, String fbaShipmentIds,
String poReferenceNos, int totalPackages, int pageNumber, int pageCount, List<CargoRow> cargoRows) {}
record CargoRow(String containerNo, String palletNo, String fbaWarehouseCode, String warehouseNo,
String fbaShipmentId, String poReferenceNo, int packageCount) {}
}
@@ -6,6 +6,7 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import com.baiyue.lastleg.dispatch.service.DispatchService;
import com.baiyue.lastleg.dispatch.service.DispatchBolPrintService;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -13,6 +14,11 @@ import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
@@ -20,6 +26,8 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import jakarta.servlet.http.HttpServletRequest;
import java.nio.charset.StandardCharsets;
@RestController
@ConditionalOnProperty(name = "lastleg.dispatch-query-api.enabled", havingValue = "true")
@@ -27,6 +35,7 @@ import org.springframework.web.server.ResponseStatusException;
@Tag(name = "Tail-leg vehicle dispatch query")
public class DispatchQueryController {
@Resource private DispatchService service;
@Resource private DispatchBolPrintService printService;
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
@@ -48,6 +57,21 @@ public class DispatchQueryController {
return ApiResponse.success(service.platformDetail(dispatchNo));
}
@GetMapping(value = "/{dispatchNo}/prints/bol", produces = MediaType.APPLICATION_PDF_VALUE)
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
@Operation(summary = "Generate dispatch BOL as an A4 PDF")
public ResponseEntity<byte[]> printBol(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo, HttpServletRequest request) {
requirePlatform(user);
DispatchBolPrintService.GeneratedPdf pdf = printService.generate(dispatchNo, request.getHeader("X-Request-Id"));
ResponseEntity.BodyBuilder response = ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF)
.cacheControl(CacheControl.noStore()).header(HttpHeaders.PRAGMA, "no-cache")
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(pdf.fileName(), StandardCharsets.UTF_8).build().toString())
.header("X-Content-Type-Options", "nosniff");
if (pdf.pageCount() != null) response.header("X-Print-Page-Count", pdf.pageCount());
return response.contentLength(pdf.content().length).body(pdf.content());
}
private void requirePlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
@@ -12,11 +12,16 @@ public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate,
int itemCount, int palletCount, int packageCount, int pickedPackageCount,
int shippedPackageCount, int remainingPackageCount, String partialCompletionReason,
LocalDateTime createdAt,
List<Item> items) {
List<Item> items, List<BolLine> bolLines) {
public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo,
String overseasWarehouseId, String overseasWarehouseName,
String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, int packageCount,
BigDecimal volumeCbm, String sourceStage) {
}
public record BolLine(String containerNo, String palletNo, String fbaWarehouseCode,
String destinationWarehouseName, String warehouseNo, String fbaShipmentId,
String poReferenceNo, int packageCount) {
}
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
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 DispatchBolMapper {
@Select("SELECT " +
"COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo," +
"MAX(NULLIF(di.pallet_no,'')) AS palletNo," +
"COALESCE(MAX(NULLIF(l.destination_warehouse_code,'')),MAX(NULLIF(di.fba_warehouse_code,''))," +
"MAX(NULLIF(di.destination_warehouse_code,''))) AS fbaWarehouseCode," +
"COALESCE(MAX(NULLIF(l.destination_warehouse_name,'')),MAX(NULLIF(di.destination_warehouse_name,''))) AS destinationWarehouseName," +
"COALESCE(MAX(NULLIF(l.inbound_code,'')),MAX(NULLIF(di.inbound_code,''))) AS warehouseNo," +
"GROUP_CONCAT(DISTINCT NULLIF(od.shipment_no,'') ORDER BY od.shipment_no SEPARATOR ', ') AS fbaShipmentId," +
"GROUP_CONCAT(DISTINCT NULLIF(od.fba_tracking_no,'') ORDER BY od.fba_tracking_no SEPARATOR ', ') AS poReferenceNo," +
"CASE WHEN MAX(l.id) IS NULL THEN COALESCE(MAX(di.package_count),0) " +
"WHEN COALESCE(MAX(a.picked_package_count),0)>0 THEN MAX(a.picked_package_count) " +
"ELSE COALESCE(MAX(a.planned_package_count),0) END AS packageCount " +
"FROM ll_dispatch_item di " +
"LEFT JOIN ll_inbound_plan_pallet p ON p.id=di.plan_pallet_id " +
"LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id " +
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id " +
"LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id " +
"LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=di.customer_id " +
"WHERE di.customer_id=#{customerId} AND di.dispatch_order_id=#{dispatchOrderId} " +
"GROUP BY di.id,l.id ORDER BY di.id,l.id")
List<DispatchResponse.BolLine> findByDispatchOrder(@Param("customerId") String customerId,
@Param("dispatchOrderId") Long dispatchOrderId);
}
@@ -0,0 +1,144 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
import com.baiyue.lastleg.client.PrintPdfClient;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import feign.FeignException;
import jakarta.annotation.Resource;
import java.math.BigDecimal;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
@Service
public class DispatchBolPrintService {
private static final String TEMPLATE = "DISPATCH_BILL_OF_LADING_A4_V2";
private static final int FIRST_PAGE_ROWS = 18;
private static final int CONTINUATION_PAGE_ROWS = 26;
private static final PrintPdfClient.PageSpec A4 = new PrintPdfClient.PageSpec(new BigDecimal("210"), new BigDecimal("297"));
@Resource private DispatchService dispatches;
@Resource private PrintPdfClient print;
@Resource private DomesticWarehouseDirectoryClient warehouses;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
public GeneratedPdf generate(String dispatchNo, String requestId) {
DispatchResponse detail = dispatches.platformDetail(dispatchNo);
if (detail.bolLines() == null || detail.bolLines().isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "排车单没有可打印的BOL明细");
}
List<PrintPdfClient.Document> documents = documents(detail);
String fileName = safeFilePart(detail.dispatchNo()) + "-BOL.pdf";
try {
ResponseEntity<byte[]> response = print.generateDispatchBol(internalToken, requestId,
new PrintPdfClient.Request(TEMPLATE, A4, fileName, documents));
byte[] content = response.getBody();
MediaType contentType = response.getHeaders().getContentType();
if (!response.getStatusCode().is2xxSuccessful() || content == null || content.length == 0
|| contentType == null || !MediaType.APPLICATION_PDF.isCompatibleWith(contentType)) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务未返回有效PDF");
}
return new GeneratedPdf(content, fileName, response.getHeaders().getFirst("X-Print-Page-Count"));
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用");
}
}
private List<PrintPdfClient.Document> documents(DispatchResponse detail) {
String shipFromAddress = warehouseAddress(detail.overseasWarehouseId());
Map<String, List<DispatchResponse.BolLine>> groups = new LinkedHashMap<>();
for (DispatchResponse.BolLine line : detail.bolLines()) groups.computeIfAbsent(text(line.fbaWarehouseCode()), ignored -> new ArrayList<>()).add(line);
List<PrintPdfClient.Document> documents = new ArrayList<>();
for (Map.Entry<String, List<DispatchResponse.BolLine>> group : groups.entrySet()) {
List<DispatchResponse.BolLine> lines = group.getValue();
int pages = pageCount(lines.size());
int total = lines.stream().mapToInt(DispatchResponse.BolLine::packageCount).sum();
String destination = lines.stream().map(DispatchResponse.BolLine::destinationWarehouseName).filter(v -> v != null && !v.isBlank()).findFirst().orElse("—");
int offset = 0;
for (int page = 0; page < pages; page++) {
int capacity = page == 0 ? FIRST_PAGE_ROWS : CONTINUATION_PAGE_ROWS;
int end = Math.min(lines.size(), offset + capacity);
List<PrintPdfClient.CargoRow> rows = lines.subList(offset, end).stream()
.map(line -> new PrintPdfClient.CargoRow(text(line.containerNo()), text(line.palletNo()), text(line.fbaWarehouseCode()),
text(line.warehouseNo()), text(line.fbaShipmentId()), text(line.poReferenceNo()), line.packageCount())).toList();
documents.add(new PrintPdfClient.Document(text(detail.overseasWarehouseName()), shipFromAddress, text(detail.carrierName()),
text(detail.fbaAppointmentNo()), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—",
group.getKey(), destination, "—", text(detail.dispatchNo()), unique(lines, true), unique(lines, false),
total, page + 1, pages, rows));
offset = end;
}
}
return List.copyOf(documents);
}
private String warehouseAddress(String warehouseId) {
long id;
try {
id = Long.parseLong(warehouseId == null ? "" : warehouseId.strip());
} catch (NumberFormatException exception) {
throw new BizException(ResultCode.VALIDATION_FAILED, "排车单未关联有效的海外仓");
}
try {
var response = warehouses.activeWarehouse(id, internalToken);
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库主数据暂不可用,无法生成BOL");
}
List<DomesticWarehouseDirectoryClient.Address> addresses = response.data().addresses();
if (addresses == null || addresses.isEmpty()) return "—";
DomesticWarehouseDirectoryClient.Address address = addresses.stream().filter(Objects::nonNull)
.filter(value -> "OPERATING".equals(value.addressType())).findFirst()
.orElseGet(() -> addresses.stream().filter(Objects::nonNull).findFirst().orElse(null));
if (address == null) return "—";
List<String> parts = new ArrayList<>();
add(parts, address.addressLine1());
add(parts, address.addressLine2());
String city = trimmed(address.city());
String state = trimmed(address.stateProvince());
String postalCode = trimmed(address.postalCode());
String region = city == null ? state : state == null ? city : city + ", " + state;
if (postalCode != null) region = region == null ? postalCode : region + " " + postalCode;
add(parts, region);
add(parts, address.countryCode());
return parts.isEmpty() ? "—" : String.join(", ", parts);
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库主数据暂不可用,无法生成BOL");
}
}
private void add(List<String> parts, String value) {
if (value != null && !value.isBlank()) parts.add(value.strip());
}
private String trimmed(String value) {
return value == null || value.isBlank() ? null : value.strip();
}
static int pageCount(int rowCount) {
if (rowCount <= FIRST_PAGE_ROWS) return 1;
return 1 + (rowCount - FIRST_PAGE_ROWS + CONTINUATION_PAGE_ROWS - 1) / CONTINUATION_PAGE_ROWS;
}
private String unique(List<DispatchResponse.BolLine> lines, boolean shipment) {
LinkedHashSet<String> values = new LinkedHashSet<>();
for (DispatchResponse.BolLine line : lines) {
String raw = shipment ? line.fbaShipmentId() : line.poReferenceNo();
if (raw != null) for (String value : raw.split(",")) if (!value.isBlank()) values.add(value.strip());
}
return values.isEmpty() ? "—" : String.join(", ", values);
}
private String dateTime(java.time.LocalDateTime value) { return value == null ? "—" : value.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); }
private String text(String value) { return value == null || value.isBlank() ? "—" : value.strip(); }
private String safeFilePart(String value) { return text(value).replaceAll("[\\\\/:*?\"<>|]", "-"); }
public record GeneratedPdf(byte[] content, String fileName, String pageCount) {}
}
@@ -12,6 +12,7 @@ import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
import com.baiyue.lastleg.dispatch.entity.DispatchDraft;
import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
@@ -52,6 +53,7 @@ public class DispatchService {
@Resource private DispatchOrderMapper orders;
@Resource private DispatchItemMapper items;
@Resource private DispatchBolMapper bolLines;
@Resource private DispatchPalletLockMapper locks;
@Resource private DispatchDraftMapper drafts;
@Resource private DispatchDraftContainerMapper draftContainers;
@@ -475,7 +477,8 @@ public class DispatchService {
public DispatchResponse detail(String customerId, String dispatchNo) {
DispatchOrder dispatch = find(customerId, dispatchNo);
return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()),
bolLines.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
}
public DispatchResponse platformDetail(String dispatchNo) {
@@ -483,7 +486,8 @@ public class DispatchService {
if (dispatch == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
}
return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()),
bolLines.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
}
@Transactional
@@ -593,6 +597,11 @@ public class DispatchService {
}
private DispatchResponse response(DispatchOrder order, List<DispatchItem> itemList) {
return response(order, itemList, List.of());
}
private DispatchResponse response(DispatchOrder order, List<DispatchItem> itemList,
List<DispatchResponse.BolLine> bolLineList) {
String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream())
.distinct().collect(Collectors.joining(","));
return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(),
@@ -609,7 +618,8 @@ public class DispatchService {
item.getWarehousePalletId(), item.getPalletNo(), item.getOverseasWarehouseId(), item.getOverseasWarehouseName(),
item.getOrderNo(), item.getOrderRefs(), item.getInboundCode(),
item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
Objects.requireNonNullElse(item.getPackageCount(), 0), item.getVolumeCbm(), item.getSourceStage())).toList());
Objects.requireNonNullElse(item.getPackageCount(), 0), item.getVolumeCbm(), item.getSourceStage())).toList(),
bolLineList == null ? List.of() : bolLineList);
}
private static String route(List<DispatchPalletCandidate> selected) {
@@ -8,7 +8,11 @@ public final class DropshipRequests {
private DropshipRequests() {}
public record Receive(@NotBlank String customerId, @NotNull Long orderId, @NotNull Long warehouseId,
@NotBlank String idempotencyKey, @NotEmpty List<@Valid Pallet> pallets) {}
public record Pallet(@NotBlank String palletNo, @NotEmpty List<@Valid SkuPackages> skuPackages) {}
public record Pallet(@NotBlank String palletNo, @NotEmpty List<@Valid SkuPackages> skuPackages,
@NotNull @Positive Integer manifestVersion,
@NotNull @Positive Integer manifestPackageCount,
@NotNull @Positive Integer manifestFragmentCount,
@NotBlank @Size(max = 64) String manifestHash) {}
public record SkuPackages(@NotBlank String skuCode, String productName, @Positive long packageCount,
@Positive long productQuantity,
@NotEmpty List<@NotBlank String> cartonMarks) {}
@@ -13,6 +13,10 @@ public class DropshipOrderProfile {
private String palletNo;
/** Immutable pallet number snapshot supplied by the first-leg handover event. */
private String sourcePalletNo;
private Integer manifestVersion;
private Integer manifestPackageCount;
private Integer manifestFragmentCount;
private String manifestHash;
private Long inventoryPositionId;
private String status;
private String idempotencyKey;
@@ -17,6 +17,14 @@ public interface DropshipCartonMapper extends BaseMapper<DropshipCarton> {
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,carton_sequence")
List<DropshipCarton> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Select("""
SELECT DISTINCT p.source_pallet_no FROM ll_dropship_carton c
JOIN ll_dropship_order_profile p ON p.id=c.profile_id AND p.customer_id=c.customer_id
JOIN ll_order o ON o.id=c.order_id AND o.customer_id=c.customer_id
WHERE o.container_no=#{containerNo} AND c.carton_mark=#{cartonMark}
""")
List<String> sourcePalletsInContainer(@Param("containerNo") String containerNo,
@Param("cartonMark") String cartonMark);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku} AND status='AVAILABLE' ORDER BY carton_sequence LIMIT #{packages} FOR UPDATE")
List<DropshipCarton> lockAvailable(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages);
@Update("UPDATE ll_dropship_carton SET status='AVAILABLE', outbound_order_no=NULL, updated_at=NOW(3) WHERE customer_id=#{customerId} AND outbound_order_no=#{outboundNo} AND status='SHIPPED'")
@@ -39,6 +39,14 @@ public interface DropshipOrderProfileMapper extends BaseMapper<DropshipOrderProf
List<DropshipOrderProfile> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND inventory_position_id=#{positionId} LIMIT 1")
DropshipOrderProfile findPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId);
@Select("""
SELECT p.* FROM ll_dropship_order_profile p
JOIN ll_order o ON o.id=p.order_id AND o.customer_id=p.customer_id
WHERE o.container_no=#{containerNo} AND p.source_pallet_no=#{sourcePalletNo}
ORDER BY p.customer_id,p.order_id,p.id
""")
List<DropshipOrderProfile> listContainerPallet(@Param("containerNo") String containerNo,
@Param("sourcePalletNo") String sourcePalletNo);
@Select("<script>" + OUTBOUND_CANDIDATE_SELECT + OUTBOUND_CANDIDATE_WHERE +
"<if test='warehouseId != null and warehouseId != \"\"'>AND pos.overseas_warehouse_id=#{warehouseId} </if>" +
@@ -51,6 +51,17 @@ public class DropshipOrderService {
Set<String> requestedPallets = request.pallets().stream().map(value -> value.palletNo().trim())
.collect(java.util.stream.Collectors.toSet());
if (!existingPallets.equals(requestedPallets)) throw conflict("Dropship order pallet snapshot conflicts with existing data");
Map<String, DropshipOrderProfile> byPallet = existing.stream().collect(Collectors.toMap(
DropshipOrderProfile::getSourcePalletNo, value -> value));
for (DropshipRequests.Pallet requested : request.pallets()) {
DropshipOrderProfile stored = byPallet.get(requested.palletNo().trim());
if (!java.util.Objects.equals(stored.getManifestVersion(), requested.manifestVersion())
|| !java.util.Objects.equals(stored.getManifestPackageCount(), requested.manifestPackageCount())
|| !java.util.Objects.equals(stored.getManifestFragmentCount(), requested.manifestFragmentCount())
|| !java.util.Objects.equals(stored.getManifestHash(), requested.manifestHash())) {
throw conflict("Dropship pallet manifest conflicts with existing data: " + requested.palletNo());
}
}
return Math.toIntExact(totalPackages);
}
@@ -104,6 +115,10 @@ public class DropshipOrderService {
DropshipOrderProfile profile = new DropshipOrderProfile();
profile.setId(IdWorker.getId()); profile.setCustomerId(request.customerId()); profile.setOrderId(order.getId());
profile.setSourcePalletNo(input.palletNo().trim());
profile.setManifestVersion(input.manifestVersion());
profile.setManifestPackageCount(input.manifestPackageCount());
profile.setManifestFragmentCount(input.manifestFragmentCount());
profile.setManifestHash(input.manifestHash().trim().toLowerCase(java.util.Locale.ROOT));
profile.setStatus("PENDING_INBOUND"); profile.setVersion(0L);
profile.setIdempotencyKey(request.idempotencyKey() + ":" + input.palletNo().trim());
profile.setCreatedAt(now); profile.setUpdatedAt(now);
@@ -29,6 +29,8 @@ public record LastlegOrderSummary(
String transferWarehouseName,
String fbaWarehouseCode,
String containerNo,
@Schema(description = "由柜级船节点门禁读取的最新标准货柜节点")
String containerNode,
Boolean abnormal,
Boolean detained,
Boolean heldChargeable,
@@ -36,7 +36,6 @@ public class LastlegOrder {
private String transferWarehouseId;
private String transferWarehouseCode;
private String transferWarehouseName;
private String containerStatus;
private Boolean abnormal;
private Boolean detained;
private Boolean holdChargeable;
@@ -17,6 +17,8 @@ public record ContainerInboundPlanPreviewResponse(
int blockedOrderCount,
int pendingOrderCount,
int pendingPalletCount,
int sourcePalletCount,
int supplementPalletCount,
int existingPlannedPalletCount,
int totalSuggestedPalletCount,
int totalPlannedPalletCount,
@@ -31,6 +33,8 @@ public record ContainerInboundPlanPreviewResponse(
int orderCount,
int pendingOrderCount,
int pendingPalletCount,
int sourcePalletCount,
int supplementPalletCount,
int existingPlannedPalletCount,
int suggestedPalletCount,
int plannedPalletCount,
@@ -52,6 +56,9 @@ public record ContainerInboundPlanPreviewResponse(
int packageCount,
BigDecimal volumeCbm,
Integer suggestedPalletCount,
int sourcePalletCount,
int targetPalletCount,
int supplementPalletCount,
Integer plannedPalletCount,
String existingPlanNo,
String planStatus,
@@ -10,6 +10,9 @@ public record InboundPlanResponse(
String planNo,
String customerId,
Long sourceOrderId,
String sourceOrderNo,
String customerName,
String orderType,
String containerNo,
String overseasWarehouseCode,
String overseasWarehouseName,
@@ -29,7 +32,9 @@ public record InboundPlanResponse(
boolean mergeRequired,
boolean urgent,
String creationMode,
String remark
String remark,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
@@ -44,7 +49,8 @@ public record InboundPlanResponse(
int actualPackageCount) {
}
public record Allocation(Long lineId, Long palletId, BigDecimal allocatedVolumeCbm,
public record Allocation(Long lineId, Long palletId, String palletNo, String palletOwnerPlanNo,
boolean palletOwnedByCurrentPlan, BigDecimal allocatedVolumeCbm,
int plannedPackageCount, int countedPackageCount, int pickedPackageCount,
BigDecimal actualVolumeCbm, String status, int version) {
}
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Data;
import java.math.BigDecimal;
@@ -31,4 +32,6 @@ public class InboundPlanPallet {
private LocalDateTime lockedAt;
private BigDecimal actualVolumeCbm;
private Integer actualPackageCount;
@Version
private Integer version;
}
@@ -16,6 +16,7 @@ public interface InboundPlanOrderMapper {
String orderType) { }
record ReceivingWarehouseSnapshot(String overseasWarehouseId, String overseasWarehouseCode,
String overseasWarehouseName) { }
record DetailOrderSnapshot(String orderNo, String customerName, String orderType) { }
record ContainerWarehouseRef(String customerId, Long orderId, String sourceContainerBookingId,
String overseasWarehouseId) { }
record ContainerPreviewOrderRow(
@@ -26,7 +27,7 @@ public interface InboundPlanOrderMapper {
String overseasWarehouseName, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName, int destinationCount,
boolean detained, int version, String planNo, String planStatus,
Integer plannedPalletCount, Integer materializedPalletCount
Integer plannedPalletCount, Integer materializedPalletCount, Integer sourcePalletCount
) { }
@Select("SELECT o.customer_id AS customerId,o.id AS orderId,o.order_no AS orderNo," +
@@ -44,7 +45,9 @@ public interface InboundPlanOrderMapper {
"o.detained AS detained,o.version AS version,p.plan_no AS planNo,p.status AS planStatus," +
"p.pallet_count AS plannedPalletCount," +
"(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " +
"AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL) AS materializedPalletCount " +
"AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL) AS materializedPalletCount," +
"(SELECT COUNT(*) FROM ll_order_source_pallet sp WHERE sp.customer_id=o.customer_id " +
"AND sp.order_id=o.id) AS sourcePalletCount " +
"FROM ll_order o LEFT JOIN ll_inbound_plan p ON p.customer_id=o.customer_id AND p.source_order_id=o.id " +
"WHERE o.source_container_booking_id=#{sourceContainerBookingId} ORDER BY o.customer_id,o.id")
List<ContainerPreviewOrderRow> containerPreviewOrders(
@@ -99,6 +102,11 @@ public interface InboundPlanOrderMapper {
ReceivingWarehouseSnapshot findReceivingWarehouse(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
@Select("SELECT order_no AS orderNo,customer_name AS customerName,order_type AS orderType " +
"FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
DetailOrderSnapshot findDetailOrder(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
@Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," +
"pallet_merge_flag='MERGED',pallet_count=#{palletCount},updated_at=#{updatedAt} " +
"WHERE customer_id=#{customerId} AND id=#{orderId}")
@@ -144,7 +144,7 @@ public class ReceivingContainerController {
@PostMapping("/current/complete")
public ApiResponse<ReceivingContainerResponses.Session> complete(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.Action request) {
@Valid @RequestBody ReceivingContainerRequests.Complete request) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.complete(identity.operatorKey(), request));
}
@@ -13,14 +13,22 @@ public final class ReceivingContainerRequests {
private ReceivingContainerRequests() { }
public record Action(@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Complete(boolean allowDifference,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Sign(@NotNull @Size(min = 3, max = 3) List<@Valid SignedPhoto> photos,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record SignedPhoto(@NotNull ReceivingPhotoRole role,
@NotBlank @Pattern(regexp = "^[1-9][0-9]{0,18}$") String fileId) { }
public record CartonResolve(@NotBlank String cartonMark) { }
public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { }
/** packageCount 是该托当前累计实收箱数,不是本次增量。 */
public record BulkReceive(@NotBlank String inboundCode, @NotBlank String palletCode,
@Positive int packageCount, @NotBlank @Size(max = 255) String idempotencyKey) { }
@Positive int packageCount, Integer expectedPalletVersion,
@NotBlank @Size(max = 255) String idempotencyKey) {
public BulkReceive(String inboundCode, String palletCode, int packageCount, String idempotencyKey) {
this(inboundCode, palletCode, packageCount, null, idempotencyKey);
}
}
public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record WholePalletReceive(@NotBlank String sourcePalletNo,
@@ -35,7 +35,7 @@ public final class ReceivingContainerResponses {
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
LocalDateTime receivingAt, LocalDateTime completedAt,
int totalPackages, int receivedPackages, int pendingPackages,
List<Inbound> inbounds) {
boolean hasDifference, List<Inbound> inbounds) {
public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName,
String status, String operatorName, int version, List<SignedPhoto> photos,
LocalDateTime preparedAt, LocalDateTime signedAt, LocalDateTime receivingAt,
@@ -43,7 +43,17 @@ public final class ReceivingContainerResponses {
int pendingPackages, List<Inbound> inbounds) {
this(containerNo, overseasWarehouseId, null, overseasWarehouseName, status, operatorName,
version, photos, preparedAt, signedAt, receivingAt, completedAt, totalPackages,
receivedPackages, pendingPackages, inbounds);
receivedPackages, pendingPackages, false, inbounds);
}
public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseCode,
String overseasWarehouseName, String status, String operatorName, int version,
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
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,
completedAt, totalPackages, receivedPackages, pendingPackages, false, inbounds);
}
}
@@ -65,7 +75,20 @@ public final class ReceivingContainerResponses {
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages,
boolean assigned, boolean mergeAllowed, boolean urgent,
String mergeHint) {
String mergeHint, String operationMode, String groupKey,
List<String> inboundCodes, String sourcePalletNo) {
public CartonResolved(String cartonMark, String inboundCode, String destinationType,
String overseasWarehouseId, String overseasWarehouseName,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode,
int plannedPalletCount, int totalPackages, int receivedPackages,
int pendingPackages, boolean assigned, boolean mergeAllowed,
boolean urgent, String mergeHint) {
this(cartonMark, inboundCode, destinationType, overseasWarehouseId, overseasWarehouseName,
destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
fbaWarehouseCode, plannedPalletCount, totalPackages, receivedPackages, pendingPackages,
assigned, mergeAllowed, urgent, mergeHint, null, null, List.of(inboundCode), null);
}
public CartonResolved(String cartonMark, String inboundCode, String destinationType,
String overseasWarehouseId, String overseasWarehouseName,
String destinationWarehouseId, String destinationWarehouseCode,
@@ -75,24 +98,41 @@ public final class ReceivingContainerResponses {
this(cartonMark, inboundCode, destinationType, overseasWarehouseId, overseasWarehouseName,
destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
fbaWarehouseCode, plannedPalletCount, totalPackages, receivedPackages,
pendingPackages, false, false, false, null);
pendingPackages, false, false, false, null, null, null, List.of(inboundCode), null);
}
}
public record PalletResolved(String palletCode, long warehouseId, String status,
String fbaWarehouseCode, String inboundCode,
String destinationType, boolean compatible) { }
String destinationType, boolean compatible, int version,
int currentReceivedPackages) {
public PalletResolved(String palletCode, long warehouseId, String status,
String fbaWarehouseCode, String inboundCode,
String destinationType, boolean compatible) {
this(palletCode, warehouseId, status, fbaWarehouseCode, inboundCode,
destinationType, compatible, 0, 0);
}
}
public record Receipt(String result, String containerNo, String inboundCode,
String destinationType, String palletCode, int acceptedPackages,
int receivedPackages, int pendingPackages, String sessionStatus,
boolean assigned, boolean mergeAllowed, boolean urgent,
String mergeHint) {
String mergeHint, String groupKey, List<String> inboundCodes) {
public Receipt(String result, String containerNo, String inboundCode,
String destinationType, String palletCode, int acceptedPackages,
int receivedPackages, int pendingPackages, String sessionStatus,
boolean assigned, boolean mergeAllowed, boolean urgent, String mergeHint) {
this(result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages,
receivedPackages, pendingPackages, sessionStatus, assigned, mergeAllowed, urgent,
mergeHint, null, List.of(inboundCode));
}
public Receipt(String result, String containerNo, String inboundCode,
String destinationType, String palletCode, int acceptedPackages,
int receivedPackages, int pendingPackages, String sessionStatus) {
this(result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages,
receivedPackages, pendingPackages, sessionStatus, false, false, false, null);
receivedPackages, pendingPackages, sessionStatus, false, false, false, null,
null, List.of(inboundCode));
}
}
@@ -0,0 +1,21 @@
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.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_receiving_fba_pallet_count")
public class ReceivingFbaPalletCount {
@TableId(type = IdType.INPUT)
private Long id;
private Long sessionId;
private String groupKey;
private String palletCode;
private Integer actualPackages;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.inboundplan.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ReceivingFbaPalletCountMapper extends BaseMapper<ReceivingFbaPalletCount> {
@Select("SELECT * FROM ll_receiving_fba_pallet_count WHERE session_id=#{sessionId} " +
"AND group_key=#{groupKey} AND pallet_code=#{palletCode} LIMIT 1")
ReceivingFbaPalletCount find(@Param("sessionId") long sessionId, @Param("groupKey") String groupKey,
@Param("palletCode") String palletCode);
@Select("SELECT COALESCE(SUM(actual_packages),0) FROM ll_receiving_fba_pallet_count " +
"WHERE session_id=#{sessionId} AND group_key=#{groupKey}")
int sumActual(@Param("sessionId") long sessionId, @Param("groupKey") String groupKey);
}
@@ -23,6 +23,7 @@ import org.springframework.stereotype.Repository;
import jakarta.annotation.Resource;
import java.util.List;
import java.util.Set;
@Repository
public class PdaWorkflowRepository {
@@ -91,6 +92,11 @@ public class PdaWorkflowRepository {
return pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>().eq(InboundPlanPallet::getPlanId, planId));
}
/** FBA destination groups may allocate several customers onto one logical pallet owned by another plan. */
public List<InboundPlanPallet> palletsByIds(Set<Long> palletIds) {
return palletIds == null || palletIds.isEmpty() ? List.of() : pallets.selectBatchIds(palletIds);
}
public List<InboundPlanPalletAllocation> allocations(Long planId) {
return allocations.selectList(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, planId));
@@ -45,6 +45,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Service
public class InboundPdaWorkflowService {
@@ -578,6 +579,58 @@ public class InboundPdaWorkflowService {
return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList();
}
/**
* 现场允许带差异结束:核平计划正常入库;差异计划只冻结为待核平,继续占用实物托盘和库位,
* 不生成客户可用库存。柜级卸货事实仍只发布一次。
*/
@Transactional
public void completeContainerWithDifferences(List<InboundPlan> containerPlans,
List<InboundPlan> reconciledPlans) {
if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans");
Set<Long> reconciledIds = reconciledPlans.stream().map(InboundPlan::getId).collect(java.util.stream.Collectors.toSet());
LocalDateTime now = LocalDateTime.now();
List<InboundPdaTask> allTasks = new ArrayList<>();
for (InboundPlan plan : containerPlans.stream().sorted(Comparator.comparing(InboundPlan::getId)).toList()) {
InboundPdaTask task = repository.lockTask(plan.getCustomerId(), plan.getId());
if (task == null || unloadPhotoFileIds(task).isEmpty() || task.getUnloadPhotoUploadedAt() == null) {
throw conflict("Every plan must have the signed unload evidence before onsite completion");
}
if (reconciledIds.contains(plan.getId())) {
CompletionContext context = completionContext(plan.getCustomerId(), plan);
completePlan(plan.getCustomerId(), context, now);
allTasks.add(context.task());
} else {
task.setStatus("PENDING_RECONCILIATION"); task.setCompletedAt(now); task.setUpdatedAt(now);
task.setVersion(value(task.getVersion()) + 1); repository.update(task);
plan.setStatus("PENDING_RECONCILIATION"); plan.setUpdatedAt(now); repository.update(plan);
allTasks.add(task);
}
}
String containerNo = containerPlans.get(0).getContainerNo();
if (containerPlans.stream().anyMatch(plan -> !Objects.equals(containerNo, plan.getContainerNo()))) {
throw conflict("Inbound plans must belong to one physical container");
}
String sourceContainerBookingId = sourceContainerBookingIdForPlans(containerPlans);
LocalDateTime photoUploadedAt = allTasks.stream().map(InboundPdaTask::getUnloadPhotoUploadedAt)
.max(LocalDateTime::compareTo).orElseThrow();
long aggregateVersion = allTasks.stream().map(InboundPdaTask::getVersion).filter(Objects::nonNull)
.mapToLong(Integer::longValue).max().orElse(1L);
unloadCompletedOutbox.publishContainerCompleted(sourceContainerBookingId, containerNo,
now.toInstant(ZoneOffset.UTC), photoUploadedAt.toInstant(ZoneOffset.UTC), aggregateVersion);
}
private String sourceContainerBookingIdForPlans(List<InboundPlan> plans) {
List<String> values = plans.stream().map(plan -> {
LastlegOrder order = orders.selectById(plan.getSourceOrderId());
if (order == null || order.getSourceContainerBookingId() == null || order.getSourceContainerBookingId().isBlank()) {
throw new IllegalStateException("Inbound plan has no source container booking ID");
}
return order.getSourceContainerBookingId();
}).distinct().toList();
if (values.size() != 1) throw conflict("Container plans reference different source container bookings");
return values.get(0);
}
private String sourceContainerBookingId(List<CompletionContext> contexts) {
List<String> bookingIds = contexts.stream().map(context -> {
LastlegOrder order = orders.selectById(context.plan().getSourceOrderId());
@@ -628,8 +681,15 @@ public class InboundPdaWorkflowService {
private void completePlan(String customerId, CompletionContext context, LocalDateTime now) {
InboundPlan plan = context.plan();
InboundPdaTask task = context.task();
List<InboundPlanPallet> receiptPallets = new ArrayList<>(repository.pallets(plan.getId()));
Set<Long> presentPalletIds = receiptPallets.stream().map(InboundPlanPallet::getId)
.collect(java.util.stream.Collectors.toSet());
Set<Long> sharedPalletIds = context.allocations().stream().map(InboundPlanPalletAllocation::getPalletId)
.filter(Objects::nonNull).filter(id -> !presentPalletIds.contains(id))
.collect(java.util.stream.Collectors.toSet());
receiptPallets.addAll(repository.palletsByIds(sharedPalletIds));
stockReceipts.stockIn(customerId, plan, task, context.lines(), context.allocations(),
repository.pallets(plan.getId()), now);
receiptPallets, now);
for (InboundPlanCarton carton : context.cartons()) {
carton.setStatus("ON_PALLET".equals(carton.getStatus()) ? "AVAILABLE" : "BULK_RECEIVED");
carton.setReceivedAt(now); carton.setUpdatedAt(now);
@@ -1164,7 +1224,10 @@ public class InboundPdaWorkflowService {
List<InboundPlanCarton> cartons) { }
private static int value(Integer value) { return value == null ? 0 : value; }
private static boolean terminal(String status) { return "COMPLETED".equals(status) || "PARTIAL_INBOUND".equals(status); }
private static boolean terminal(String status) {
return "COMPLETED".equals(status) || "PARTIAL_INBOUND".equals(status)
|| "PENDING_RECONCILIATION".equals(status);
}
private static BigDecimal volume(BigDecimal value) { return value == null ? ZERO_VOLUME : value; }
private static BigDecimal nonNegative(BigDecimal value) { return value.signum() < 0 ? ZERO_VOLUME : value.setScale(4, RoundingMode.HALF_UP); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
@@ -20,15 +20,20 @@ import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole;
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.entity.ReceivingSkuReceipt;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
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.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.MapperFeature;
@@ -74,8 +79,11 @@ public class ReceivingContainerService {
@Resource private LastlegOrderDetailMapper orderDetails;
@Resource private ReceivingContainerSessionMapper sessions;
@Resource private ReceivingContainerOperationMapper operations;
@Resource private ReceivingFbaPalletCountMapper fbaPalletCounts;
@Resource private ReceivingSkuReceiptMapper skuReceipts;
@Resource private ContainerShippingGateMapper shippingGates;
@Resource private DropshipCartonMapper dropshipCartons;
@Resource private DropshipOrderProfileMapper dropshipProfiles;
@Resource private WarehouseResourceGateway warehouse;
@Resource private UnloadPhotoFileGateway unloadPhotos;
@Resource private InboundPdaWorkflowService workflow;
@@ -246,13 +254,34 @@ public class ReceivingContainerService {
public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey,
ReceivingContainerRequests.CartonResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey);
List<InboundRef> currentRefs = refs(session.getContainerNo());
String sourcePalletNo = dropshipSourcePallet(currentRefs, session.getContainerNo(), request.cartonMark());
if (sourcePalletNo != null) {
List<DropshipPalletRef> palletRefs = dropshipPalletRefs(session.getContainerNo(), sourcePalletNo);
requireCompleteManifest(session.getContainerNo(), sourcePalletNo);
List<InboundRef> group = palletRefs.stream().map(DropshipPalletRef::ref).distinct().toList();
InboundRef first = group.get(0);
List<String> inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
int total = palletRefs.stream().mapToInt(value -> value(value.allocation().getPlannedPackageCount())).sum();
int received = palletRefs.stream().mapToInt(value -> value(value.allocation().getPickedPackageCount())).sum();
ReceivingContainerResponses.Inbound inbound = inbound(first);
return new ReceivingContainerResponses.CartonResolved(request.cartonMark().trim(),
inboundCodes.get(0), inbound.destinationType(), inbound.overseasWarehouseId(),
inbound.overseasWarehouseName(), inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(),
inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), 1, total, received,
Math.max(0, total-received), true, false, urgent(first.plan()), null,
"DROPSHIP_PALLET", "DP:" + sourcePalletNo, inboundCodes, sourcePalletNo);
}
String inboundCode = cartonInboundCode(request.cartonMark());
List<InboundRef> matches = refs(session.getContainerNo()).stream()
List<InboundRef> matches = currentRefs.stream()
.filter(value -> inboundCode.equals(value.line().getInboundCode())).toList();
CartonRef carton;
if (matches.size() > 1) {
throw conflict("Carton mark inbound code is ambiguous in the current container");
} else if (matches.size() == 1 && "FBA".equals(destinationType(matches.get(0).line(), matches.get(0).order()))) {
if (request.cartonMark().trim().equals(inboundCode)) {
throw conflict("Scan one FBA carton code, not the inbound code");
}
carton = new CartonRef(matches.get(0));
} else if (matches.size() == 1) {
carton = requireCartonRef(matches.get(0).order().getCustomerId(), session.getContainerNo(), request.cartonMark());
@@ -260,6 +289,22 @@ public class ReceivingContainerService {
carton = requireUnmatchedFbaCartonPrefix(inboundCode);
}
InboundRef ref = carton.ref();
if ("FBA".equals(destinationType(ref.line(), ref.order()))
&& !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) {
List<InboundRef> group = requireFbaPlanRefs(session.getContainerNo(), ref);
List<String> inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
int total = group.stream().mapToInt(value -> value(value.line().getRequiredPackageCount())).sum();
int received = group.stream().mapToInt(this::receivedLinePackages).sum();
ReceivingContainerResponses.Inbound inbound = inbound(ref);
int palletCount = (int) group.stream().flatMap(value -> pallets.selectList(
new LambdaQueryWrapper<InboundPlanPallet>().eq(InboundPlanPallet::getPlanId, value.plan().getId())).stream())
.map(InboundPlanPallet::getWarehousePalletId).filter(Objects::nonNull).distinct().count();
return new ReceivingContainerResponses.CartonResolved(request.cartonMark().trim(), inboundCodes.get(0),
"FBA", inbound.overseasWarehouseId(), inbound.overseasWarehouseName(), inbound.destinationWarehouseId(),
inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(),
palletCount, total, received, Math.max(0,total-received), true, false, urgent(ref.plan()), null,
"FBA_GROUP", fbaGroupKey(session.getContainerNo(), ref), inboundCodes, null);
}
ReceivingContainerResponses.Inbound inbound = inbound(ref);
return new ReceivingContainerResponses.CartonResolved(request.cartonMark().trim(),
inbound.inboundCode(), inbound.destinationType(), inbound.overseasWarehouseId(),
@@ -273,10 +318,26 @@ public class ReceivingContainerService {
ReceivingContainerRequests.PalletResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey);
InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode());
PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), false);
List<InboundRef> group = "FBA".equals(destinationType(ref.line(), ref.order()))
&& !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())
? requireFbaPlanRefs(session.getContainerNo(), ref) : List.of(ref);
PalletSelection selection = group.size() == 1 && !"FBA".equals(destinationType(ref.line(), ref.order()))
? resolveReceivingPallet(ref, request.palletCode(), false) : resolveFbaGroupPallet(group, request.palletCode());
WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource();
int received = selection.pallet() == null ? 0 : group.stream().mapToInt(value -> allocations.selectList(
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, value.plan().getId())
.eq(InboundPlanPalletAllocation::getLineId, value.line().getId())
.eq(InboundPlanPalletAllocation::getPalletId, selection.pallet().getId())).stream()
.mapToInt(item -> value(item.getPickedPackageCount())).sum()).sum();
if (selection.pallet() != null && "FBA".equals(destinationType(ref.line(), ref.order()))) {
ReceivingFbaPalletCount physical = fbaPalletCounts.find(session.getId(),
fbaGroupKey(session.getContainerNo(), ref), pallet.palletNo());
if (physical != null) received = value(physical.getActualPackages());
}
return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(),
pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true);
pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true,
selection.pallet() == null ? 0 : value(selection.pallet().getVersion()), received);
}
@Transactional
@@ -286,7 +347,6 @@ public class ReceivingContainerService {
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
ReceivingContainerSession session = requireSigned(operatorKey);
InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode());
String customerId = ref.order().getCustomerId();
if ("ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) {
throw conflict("One-click dropship inbound must be operated by complete source pallet");
}
@@ -294,31 +354,70 @@ public class ReceivingContainerService {
throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden");
}
List<InboundRef> fbaPlanRefs = requireFbaPlanRefs(session.getContainerNo(), ref);
List<FbaSkuAllocation> skuAllocations = allocateFbaSkuReceipts(customerId, fbaPlanRefs, request.packageCount());
skuAllocations.forEach(allocation -> workflow.validateBulkResolved(customerId, allocation.ref().plan(),
allocation.ref().line(), allocation.packages()));
PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), true);
PalletSelection selection = resolveFbaGroupPallet(fbaPlanRefs, request.palletCode());
WarehouseResourceGateway.WarehousePalletReference resource = selection.resource();
InboundPlanPallet pallet = selection.pallet();
Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
if (request.expectedPalletVersion() != null && !Objects.equals(request.expectedPalletVersion(), pallet.getVersion())) {
throw conflict("Pallet receiving version changed; refresh the group before saving");
}
int allocatedOnPallet = fbaPlanRefs.stream().mapToInt(value -> allocations.selectList(
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, value.plan().getId())
.eq(InboundPlanPalletAllocation::getLineId, value.line().getId())
.eq(InboundPlanPalletAllocation::getPalletId, pallet.getId())).stream()
.mapToInt(item -> value(item.getPickedPackageCount())).sum()).sum();
String groupKey = fbaGroupKey(session.getContainerNo(), ref);
ReceivingFbaPalletCount physicalCount = fbaPalletCounts.find(session.getId(), groupKey, resource.palletNo());
int previous = physicalCount == null ? allocatedOnPallet : value(physicalCount.getActualPackages());
if (request.packageCount() < previous) {
throw conflict("Cumulative pallet quantity cannot be reduced after saving; use correction operation");
}
int physicalDelta = request.packageCount() - previous;
List<String> inboundCodes = fbaPlanRefs.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
if (physicalDelta == 0) {
int total = fbaPlanRefs.stream().mapToInt(value -> value(value.line().getRequiredPackageCount())).sum();
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("ALREADY_RECEIVED",
session.getContainerNo(), inboundCodes.get(0), "FBA", resource.palletNo(), 0,
fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum(),
Math.max(0,total-fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum()), session.getStatus(),
true, mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()), groupKey, inboundCodes);
save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result);
return result;
}
int groupTotal = fbaPlanRefs.stream().mapToInt(value -> value(value.line().getRequiredPackageCount())).sum();
int groupReceived = fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum();
int allocatable = Math.min(physicalDelta, Math.max(0, groupTotal - groupReceived));
List<FbaSkuAllocation> skuAllocations = allocateFbaSkuReceipts(fbaPlanRefs, allocatable);
skuAllocations.forEach(allocation -> workflow.validateBulkResolved(allocation.ref().order().getCustomerId(), allocation.ref().plan(),
allocation.ref().line(), allocation.packages()));
String palletCustomerId = pallet.getCustomerId();
InboundRef palletOwner = fbaPlanRefs.stream().filter(value -> Objects.equals(value.plan().getId(), pallet.getPlanId()))
.findFirst().orElseThrow(() -> conflict("FBA pallet does not belong to the current destination group"));
Long resourceLockId = allocatable == 0 ? null
: workflow.assignResolvedPallet(palletCustomerId, palletOwner.plan(), pallet, resource);
try {
int accepted = 0;
for (FbaSkuAllocation allocation : skuAllocations) {
accepted += workflow.receiveBulkResolved(customerId, allocation.ref().plan(), allocation.ref().line(), pallet,
String allocationCustomerId = allocation.ref().order().getCustomerId();
accepted += workflow.receiveBulkResolved(allocationCustomerId, allocation.ref().plan(), allocation.ref().line(), pallet,
allocation.packages(), operatorKey, operatorName);
recordSkuReceipt(session, allocation, pallet);
}
savePhysicalFbaCount(session.getId(), groupKey, resource.palletNo(), request.packageCount(), physicalCount);
markReceiving(session);
int received = fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum();
int total = fbaPlanRefs.stream().mapToInt(refValue -> value(refValue.line().getRequiredPackageCount())).sum();
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), ref.line().getInboundCode(), "FBA", resource.palletNo(), accepted,
boolean overReceived = fbaPalletCounts.sumActual(session.getId(), groupKey) > total;
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt(
overReceived ? "DIFFERENCE_RECORDED" : "RECEIVED",
session.getContainerNo(), inboundCodes.get(0), "FBA", resource.palletNo(), physicalDelta,
received, Math.max(0, total - received), session.getStatus(), assigned(ref.plan()),
mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()));
mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()),
groupKey, inboundCodes);
save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result);
return result;
} catch (RuntimeException failure) {
workflow.compensateReservation(customerId, resourceLockId, failure);
workflow.compensateReservation(palletCustomerId, resourceLockId, failure);
throw failure;
}
}
@@ -370,59 +469,89 @@ public class ReceivingContainerService {
ReceivingContainerOperation replay = replay(operatorKey, "PALLET_RECEIVE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
ReceivingContainerSession session = requireSigned(operatorKey);
List<DropshipPalletRef> matches = new ArrayList<>();
for (InboundRef ref : refs(session.getContainerNo())) {
if (!"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue;
String customerId = ref.order().getCustomerId();
List<InboundPlanPallet> sourcePallets = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, customerId)
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())
.eq(InboundPlanPallet::getSourcePalletNo, request.sourcePalletNo()));
for (InboundPlanPallet pallet : sourcePallets) {
List<InboundPlanPalletAllocation> palletAllocations = allocations.selectList(
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getCustomerId, customerId)
.eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId())
.eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())
.eq(InboundPlanPalletAllocation::getPalletId, pallet.getId()));
palletAllocations.forEach(allocation -> matches.add(new DropshipPalletRef(ref, pallet, allocation)));
String sourcePalletNo = dropshipSourcePallet(session.getContainerNo(), request.sourcePalletNo());
if (sourcePalletNo == null) throw notFound("DP or carton is not part of the current container");
requireCompleteManifest(session.getContainerNo(), sourcePalletNo);
List<DropshipPalletRef> matches = dropshipPalletRefs(session.getContainerNo(), sourcePalletNo);
boolean alreadyReceived = matches.stream().allMatch(match ->
value(match.allocation().getPickedPackageCount()) == value(match.allocation().getPlannedPackageCount()));
int accepted = 0;
if (!alreadyReceived) {
if (matches.stream().anyMatch(match -> value(match.allocation().getPickedPackageCount()) != 0)) {
throw conflict("DP pallet is only partially received; reconcile it before retrying");
}
for (DropshipPalletRef match : matches) {
int packages = value(match.allocation().getPlannedPackageCount());
workflow.pickWholePallet(match.ref().order().getCustomerId(), match.ref().plan().getPlanNo(),
match.allocation().getId(), new PdaRequests.WholePalletOperation(
request.idempotencyKey() + ":PICK:" + match.allocation().getId(), operatorKey, operatorName));
accepted += packages;
match.allocation().setPickedPackageCount(packages);
}
}
if (matches.isEmpty()) throw notFound("Source pallet is not part of the current container");
if (matches.size() != 1) throw conflict("Source pallet is duplicated inside the current container");
DropshipPalletRef match = matches.get(0);
String customerId = match.ref().order().getCustomerId();
int accepted = value(match.allocation().getPlannedPackageCount());
workflow.pickWholePallet(customerId, match.ref().plan().getPlanNo(), match.allocation().getId(),
new PdaRequests.WholePalletOperation(request.idempotencyKey() + ":PICK", operatorKey, operatorName));
markReceiving(session);
ReceivingContainerResponses.Inbound inbound = inbound(match.ref());
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), match.pallet().getPalletNo(),
accepted, inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus(),
inbound.assigned(), inbound.mergeAllowed(), inbound.urgent(), inbound.mergeHint());
List<InboundRef> group = matches.stream().map(DropshipPalletRef::ref).distinct().toList();
List<String> inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
int total = matches.stream().mapToInt(value -> value(value.allocation().getPlannedPackageCount())).sum();
int received = matches.stream().mapToInt(value -> value(value.allocation().getPickedPackageCount())).sum();
InboundRef first = group.get(0);
ReceivingContainerResponses.Inbound inbound = inbound(first);
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt(
alreadyReceived ? "ALREADY_RECEIVED" : "RECEIVED", session.getContainerNo(), inboundCodes.get(0),
inbound.destinationType(), sourcePalletNo, accepted, received, Math.max(0,total-received), session.getStatus(),
true, false, urgent(first.plan()), null, "DP:" + sourcePalletNo, inboundCodes);
save(session, "PALLET_RECEIVE", request.idempotencyKey(), request, result);
return result;
}
@Transactional(isolation = Isolation.READ_COMMITTED)
public ReceivingContainerResponses.Session complete(String operatorKey,
ReceivingContainerRequests.Action request) {
ReceivingContainerRequests.Complete request) {
ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
ReceivingContainerSession session = requireSigned(operatorKey);
List<InboundPlan> containerPlans = requirePlans(session.getContainerNo());
Totals totals = totals(containerPlans);
if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container");
validateFbaSkuBalance(session.getContainerNo());
workflow.completeContainer(containerPlans);
boolean hasDifference = totals.total() != totals.received() || hasFbaOverReceipt(session, containerPlans);
if (hasDifference && !request.allowDifference()) {
throw conflict("Container has a receiving difference; confirm onsite completion with difference");
}
if (!hasDifference) {
validateFbaSkuBalance(session.getContainerNo());
workflow.completeContainer(containerPlans);
} else {
workflow.completeContainerWithDifferences(containerPlans, reconciledPlans(containerPlans));
}
LocalDateTime now = LocalDateTime.now();
session.setStatus("COMPLETED"); session.setCompletedAt(now); session.setUpdatedAt(now);
session.setStatus(hasDifference ? "COMPLETED_WITH_DIFFERENCE" : "COMPLETED");
session.setCompletedAt(now); session.setUpdatedAt(now);
session.setActiveOperatorKey(null); session.setActiveContainerKey(null); update(session);
ReceivingContainerResponses.Session result = snapshot(session, containerPlans);
save(session, "COMPLETE", request.idempotencyKey(), request, result); return result;
}
private List<InboundPlan> reconciledPlans(List<InboundPlan> containerPlans) {
Set<Long> complete = refs(containerPlans.get(0).getContainerNo()).stream()
.collect(Collectors.groupingBy(value -> value.plan().getId(), LinkedHashMap::new, Collectors.toList()))
.entrySet().stream().filter(entry -> entry.getValue().stream().allMatch(ref ->
receivedLinePackages(ref) == value(ref.line().getRequiredPackageCount())))
.map(Map.Entry::getKey).collect(Collectors.toSet());
Set<String> incompleteFbaGroups = refs(containerPlans.get(0).getContainerNo()).stream()
.filter(ref -> !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType()))
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
.filter(ref -> !complete.contains(ref.plan().getId()))
.map(ref -> normalizedFba(ref.line().getFbaWarehouseCode())).collect(Collectors.toSet());
refs(containerPlans.get(0).getContainerNo()).stream()
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
.filter(ref -> fbaGroupHasOverReceipt(findSession(containerPlans.get(0).getContainerNo()), ref))
.map(ref -> normalizedFba(ref.line().getFbaWarehouseCode())).forEach(incompleteFbaGroups::add);
return containerPlans.stream().filter(plan -> complete.contains(plan.getId()))
.filter(plan -> refs(plan.getContainerNo()).stream().filter(ref -> Objects.equals(ref.plan().getId(), plan.getId()))
.noneMatch(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))
&& incompleteFbaGroups.contains(normalizedFba(ref.line().getFbaWarehouseCode()))))
.toList();
}
private ReceivingContainerSession requireCurrent(String operatorKey, boolean lock) {
String key = activeOperatorKey(operatorKey);
ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(key)
@@ -503,7 +632,8 @@ public class ReceivingContainerService {
List<InboundPlan> containerPlans = planFacts.stream()
.filter(plan -> containerNo.equals(plan.getContainerNo())).toList();
boolean allPlansCompleted = !containerPlans.isEmpty()
&& containerPlans.stream().allMatch(plan -> "COMPLETED".equals(plan.getStatus()));
&& containerPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION")
.contains(plan.getStatus()));
boolean shippingReady = shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1;
boolean operable = allPlansReady && shippingReady && warehouseSnapshotConsistent;
int pending = Math.max(0, totalOrders - plannedOrders);
@@ -565,7 +695,8 @@ public class ReceivingContainerService {
if (values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) {
throw conflict("Every inbound plan in the receiving container must have an owning customer");
}
if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) {
if (values.stream().allMatch(value -> List.of("COMPLETED", "PENDING_RECONCILIATION")
.contains(value.getStatus()))) {
throw conflict("Receiving container is already completed");
}
return values;
@@ -634,16 +765,20 @@ public class ReceivingContainerService {
private List<InboundRef> requireFbaPlanRefs(String containerNo, InboundRef selected) {
List<InboundRef> values = refs(containerNo).stream()
.filter(value -> Objects.equals(value.plan().getId(), selected.plan().getId()))
.sorted(Comparator.comparing(value -> value.line().getId())).toList();
.filter(value -> !"ONE_CLICK_DROPSHIP".equals(value.order().getOrderType()))
.filter(value -> "FBA".equals(destinationType(value.line(), value.order())))
.filter(value -> Objects.equals(normalizedFba(value.line().getFbaWarehouseCode()),
normalizedFba(selected.line().getFbaWarehouseCode())))
.sorted(Comparator.comparing((InboundRef value) -> value.line().getInboundCode())
.thenComparing(value -> value.line().getId())).toList();
if (values.isEmpty() || values.stream().anyMatch(value ->
!"FBA".equals(destinationType(value.line(), value.order())))) {
throw conflict("FBA bulk receiving requires every line in the selected inbound plan to be FBA");
throw conflict("FBA receiving group is invalid");
}
Set<String> destinations = values.stream().map(value -> trim(value.line().getFbaWarehouseCode()))
.collect(Collectors.toSet());
if (destinations.size() != 1 || destinations.contains(null)) {
throw conflict("FBA bulk receiving requires one destination warehouse per inbound plan");
throw conflict("FBA receiving group requires one destination warehouse");
}
return values;
}
@@ -694,6 +829,29 @@ public class ReceivingContainerService {
return new PalletSelection(pallet, resource);
}
private PalletSelection resolveFbaGroupPallet(List<InboundRef> refs, String palletCode) {
String normalized = palletCode.trim();
List<InboundPlanPallet> matches = refs.stream().flatMap(ref -> pallets.selectList(
new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream())
.filter(value -> matchesPlannedPalletCode(value, normalized)).filter(this::hasConfirmedReservation)
.collect(Collectors.toMap(InboundPlanPallet::getId, value -> value, (left,right)->left,
LinkedHashMap::new)).values().stream().toList();
if (matches.isEmpty()) throw notFound("Pallet code is not part of the current FBA destination group");
if (matches.size() != 1) throw conflict("Pallet code is duplicated in the current FBA destination group");
InboundPlanPallet pallet = matches.get(0);
InboundRef owner = refs.stream().filter(ref -> Objects.equals(ref.plan().getId(), pallet.getPlanId()))
.findFirst().orElseThrow(() -> conflict("Pallet owner plan is not part of the FBA destination group"));
long warehouseId = owner.plan().getOverseasWarehouseId() == null
? Long.parseLong(owner.order().getOverseasWarehouseId()) : owner.plan().getOverseasWarehouseId();
WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolveReservedPallet(
pallet.getPalletNo(), owner.order().getCustomerId(), warehouseId, pallet.getWarehousePalletId(),
pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId());
validatePallet(owner, resource);
return new PalletSelection(pallet, resource);
}
private boolean matchesPlannedPalletCode(InboundPlanPallet pallet, String scannedCode) {
return scannedCode.equals(trim(pallet.getPalletNo()))
|| scannedCode.equals(trim(pallet.getSourcePalletNo()));
@@ -750,6 +908,68 @@ public class ReceivingContainerService {
return Objects.equals(line.getDestinationWarehouseCode(), pallet.getDestinationWarehouseCode());
}
private String dropshipSourcePallet(String containerNo, String scanCode) {
return dropshipSourcePallet(refs(containerNo), containerNo, scanCode);
}
private String dropshipSourcePallet(List<InboundRef> currentRefs, String containerNo, String scanCode) {
String normalized = trim(scanCode);
if (normalized == null) return null;
List<InboundRef> dropshipRefs = currentRefs.stream()
.filter(ref -> "ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())).toList();
if (dropshipRefs.isEmpty()) return null;
boolean direct = normalized.toUpperCase(java.util.Locale.ROOT).startsWith("DP") && dropshipRefs.stream()
.anyMatch(ref -> pallets.selectCount(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())
.eq(InboundPlanPallet::getSourcePalletNo, normalized)) > 0);
if (direct) return normalized;
List<String> resolved = dropshipCartons.sourcePalletsInContainer(containerNo, normalized);
if (resolved.size() > 1) throw conflict("Carton belongs to more than one DP in the current container");
return resolved.isEmpty() ? null : resolved.get(0);
}
private List<DropshipPalletRef> dropshipPalletRefs(String containerNo, String sourcePalletNo) {
List<DropshipPalletRef> result = new ArrayList<>();
for (InboundRef ref : refs(containerNo)) {
if (!"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue;
List<InboundPlanPallet> sourcePallets = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())
.eq(InboundPlanPallet::getSourcePalletNo, sourcePalletNo));
for (InboundPlanPallet pallet : sourcePallets) {
allocations.selectList(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getCustomerId, ref.order().getCustomerId())
.eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId())
.eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())
.eq(InboundPlanPalletAllocation::getPalletId, pallet.getId()))
.forEach(allocation -> result.add(new DropshipPalletRef(ref, pallet, allocation)));
}
}
if (result.isEmpty()) throw notFound("DP is not part of the current container");
Set<Long> physicalPallets = result.stream().map(value -> value.pallet().getWarehousePalletId())
.filter(Objects::nonNull).collect(Collectors.toSet());
if (physicalPallets.size() != 1) throw conflict("DP customer fragments do not share one physical pallet");
return result;
}
private void requireCompleteManifest(String containerNo, String sourcePalletNo) {
List<DropshipOrderProfile> profiles = dropshipProfiles.listContainerPallet(containerNo, sourcePalletNo);
if (profiles.isEmpty()) throw notFound("DP manifest was not found in the current container");
Set<Integer> versions = profiles.stream().map(DropshipOrderProfile::getManifestVersion).collect(Collectors.toSet());
Set<Integer> totals = profiles.stream().map(DropshipOrderProfile::getManifestPackageCount).collect(Collectors.toSet());
Set<Integer> fragments = profiles.stream().map(DropshipOrderProfile::getManifestFragmentCount).collect(Collectors.toSet());
Set<String> hashes = profiles.stream().map(DropshipOrderProfile::getManifestHash).collect(Collectors.toSet());
if (versions.size()!=1 || totals.size()!=1 || fragments.size()!=1 || hashes.size()!=1) {
throw conflict("DP manifest fragments are inconsistent");
}
long customerFragments = profiles.stream().map(DropshipOrderProfile::getCustomerId).distinct().count();
long cartons = profiles.stream().mapToLong(profile ->
dropshipCartons.listProfile(profile.getCustomerId(), profile.getId()).size()).sum();
if (customerFragments != fragments.iterator().next() || cartons != totals.iterator().next()) {
throw conflict("DP manifest fragments are incomplete; wait for all customer fragments");
}
}
private record PalletSelection(InboundPlanPallet pallet,
WarehouseResourceGateway.WarehousePalletReference resource) { }
@@ -764,7 +984,40 @@ public class ReceivingContainerService {
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(),
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds);
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received),
hasFbaOverReceipt(session, containerPlans), inbounds);
}
private void savePhysicalFbaCount(long sessionId, String groupKey, String palletCode, int actualPackages,
ReceivingFbaPalletCount existing) {
LocalDateTime now = LocalDateTime.now();
if (existing == null) {
ReceivingFbaPalletCount value = new ReceivingFbaPalletCount();
value.setId(IdWorker.getId()); value.setSessionId(sessionId); value.setGroupKey(groupKey);
value.setPalletCode(palletCode); value.setActualPackages(actualPackages);
value.setCreatedAt(now); value.setUpdatedAt(now); fbaPalletCounts.insert(value);
} else {
existing.setActualPackages(actualPackages); existing.setUpdatedAt(now); fbaPalletCounts.updateById(existing);
}
}
private boolean hasFbaOverReceipt(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
if (session == null || containerPlans == null || containerPlans.isEmpty()) return false;
return refs(session.getContainerNo()).stream()
.filter(ref -> !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType()))
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
.anyMatch(ref -> fbaGroupHasOverReceipt(session, ref));
}
private boolean fbaGroupHasOverReceipt(ReceivingContainerSession session, InboundRef ref) {
if (session == null || session.getId() == null) return false;
int planned = requireFbaPlanRefs(session.getContainerNo(), ref).stream()
.mapToInt(value -> value(value.line().getRequiredPackageCount())).sum();
return fbaPalletCounts.sumActual(session.getId(), fbaGroupKey(session.getContainerNo(), ref)) > planned;
}
private ReceivingContainerSession findSession(String containerNo) {
return sessions.findActiveByContainer(activeContainerKey(containerNo));
}
private ReceivingContainerResponses.Inbound inbound(InboundRef ref) {
@@ -844,10 +1097,11 @@ public class ReceivingContainerService {
throw conflict("Unload photo file ID must be a positive decimal string");
}
private List<FbaSkuAllocation> allocateFbaSkuReceipts(String customerId, List<InboundRef> refs, int packageCount) {
private List<FbaSkuAllocation> allocateFbaSkuReceipts(List<InboundRef> refs, int packageCount) {
int remaining = packageCount;
List<FbaSkuAllocation> result = new ArrayList<>();
for (InboundRef ref : refs) {
String customerId = ref.order().getCustomerId();
int linePending = value(ref.line().getRequiredPackageCount()) - receivedLinePackages(ref);
List<SkuExpectation> expectations = skuExpectations(ref);
int skuPending = expectations.stream().mapToInt(expectation -> expectation.expectedPackages()
@@ -958,6 +1212,15 @@ public class ReceivingContainerService {
return value;
}
private String fbaGroupKey(String containerNo, InboundRef ref) {
return "FBA:" + containerNo + ":" + normalizedFba(ref.line().getFbaWarehouseCode());
}
private String normalizedFba(String value) {
String normalized = trim(value);
return normalized == null ? null : normalized.toUpperCase(java.util.Locale.ROOT);
}
private void markReceiving(ReceivingContainerSession session) {
if (!"RECEIVING".equals(session.getStatus())) {
session.setStatus("RECEIVING"); session.setReceivingAt(LocalDateTime.now());
@@ -253,7 +253,7 @@ public class ContainerInboundPlanConversionCoordinator {
throw new ResponseStatusException(HttpStatus.CONFLICT, "计划托数不能少于已生成的头程托盘码数量");
}
String fbaCode = trimToNull(order.fbaWarehouseCode());
if (fbaCode != null) {
if (fbaCode != null && !"ONE_CLICK_DROPSHIP".equals(order.orderType())) {
for (String palletCode : sourcePalletNos) {
PalletCodeParts parts = palletCodeParts(preview.containerNo(), palletCode);
String previousPrefix = prefixByFba.putIfAbsent(fbaCode, parts.prefix());
@@ -329,10 +329,7 @@ public class ContainerInboundPlanConversionCoordinator {
throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次包含重复业务键");
}
}
if (response.items().stream().map(WarehouseResourceGateway.PalletBatchItem::palletId).distinct().count() != response.items().size()
|| response.items().stream().map(WarehouseResourceGateway.PalletBatchItem::resourceLockId).distinct().count() != response.items().size()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次包含重复托盘或资源锁");
}
validateSharedPallets(preparation.items(), byKey);
return preparation.items().stream().collect(Collectors.groupingBy(PreparedItem::orderId)).values().stream()
.map(items -> {
PreparedItem first = items.get(0);
@@ -356,6 +353,22 @@ public class ContainerInboundPlanConversionCoordinator {
return value;
}
private void validateSharedPallets(List<PreparedItem> expected,
Map<String, WarehouseResourceGateway.PalletBatchItem> actual) {
Map<Long, List<PreparedItem>> byPallet = expected.stream().collect(Collectors.groupingBy(item ->
requiredReservation(actual.get(item.businessKey()), item).palletId()));
for (List<PreparedItem> values : byPallet.values()) {
if (values.size() == 1) continue;
Set<String> codes = values.stream().map(PreparedItem::palletCode).collect(Collectors.toSet());
Set<Long> locks = values.stream().map(item -> requiredReservation(actual.get(item.businessKey()), item).resourceLockId())
.collect(Collectors.toSet());
if (codes.size() != 1 || codes.iterator().next() == null
|| !codes.iterator().next().toUpperCase(java.util.Locale.ROOT).startsWith("DP") || locks.size() != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "只有同一码的一件代发 DP 才能共享实物托盘");
}
}
}
private void requireBatch(WarehouseResourceGateway.PalletBatchResponse response, String batchKey, int size,
String... acceptedStatuses) {
if (response == null || response.batchId() <= 0 || response.attemptNo() <= 0
@@ -207,6 +207,10 @@ public class InboundPlanService {
items.stream().filter(InboundPlanService::requiresContainerConversion)
.map(InboundPlanService::pendingPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(),
items.stream().filter(InboundPlanService::requiresContainerConversion)
.mapToInt(ContainerInboundPlanPreviewResponse.Order::sourcePalletCount).sum(),
items.stream().filter(InboundPlanService::requiresContainerConversion)
.mapToInt(ContainerInboundPlanPreviewResponse.Order::supplementPalletCount).sum(),
items.stream().filter(item -> "PLANNED".equals(item.readiness()))
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(),
@@ -220,6 +224,10 @@ public class InboundPlanService {
int pendingPallets = orders.stream().filter(InboundPlanService::requiresContainerConversion)
.map(InboundPlanService::pendingPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
int sourcePallets = orders.stream().filter(InboundPlanService::requiresContainerConversion)
.mapToInt(ContainerInboundPlanPreviewResponse.Order::sourcePalletCount).sum();
int supplementPallets = orders.stream().filter(InboundPlanService::requiresContainerConversion)
.mapToInt(ContainerInboundPlanPreviewResponse.Order::supplementPalletCount).sum();
int existingPallets = orders.stream().filter(item -> "PLANNED".equals(item.readiness()))
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
@@ -228,7 +236,7 @@ public class InboundPlanService {
bookingId, containerNo, warehouseId, snapshotVersion, orders));
return new ContainerInboundPlanPreviewResponse(bookingId, containerNo, warehouseId, warehouseCode,
warehouseName, snapshotVersion, snapshotHash, orders.size(), orders.size() - blocked, blocked,
pendingOrders, pendingPallets, existingPallets,
pendingOrders, pendingPallets, sourcePallets, supplementPallets, existingPallets,
orders.stream().map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(),
orders.stream().map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
@@ -258,6 +266,12 @@ public class InboundPlanService {
|| row.packageCount() > 0 && row.volumeCbm().signum() == 0) blockers.add("CARGO_FACT_INVALID");
}
Integer suggested = suggestedPalletCount(row, blockers);
int sourcePalletCount = "ONE_CLICK_DROPSHIP".equals(row.orderType())
? Objects.requireNonNullElse(suggested, 0)
: Objects.requireNonNullElse(row.sourcePalletCount(), 0);
int targetPalletCount = Objects.requireNonNullElse(row.plannedPalletCount(),
Objects.requireNonNullElse(suggested, 0));
int supplementPalletCount = Math.max(0, targetPalletCount - sourcePalletCount);
if (!planned && (suggested == null || suggested <= 0)) blockers.add("PALLET_COUNT_NOT_READY");
String destinationType = destinationType(row);
String fbaWarehouseCode = isFbaDestination(row) ? trimToNull(row.fbaWarehouseCode()) : null;
@@ -281,7 +295,8 @@ public class InboundPlanService {
String.valueOf(row.orderId()), trimToNull(row.orderNo()) == null ? row.sourceOrderNo() : row.orderNo(),
row.inboundCode(), row.orderType(), destinationType, row.destinationWarehouseId(),
destinationCode, row.destinationWarehouseName(), fbaWarehouseCode, row.packageCount(), row.volumeCbm(),
suggested, row.plannedPalletCount(), row.planNo(), row.planStatus(), row.version(), readiness,
suggested, sourcePalletCount, targetPalletCount, supplementPalletCount,
row.plannedPalletCount(), row.planNo(), row.planStatus(), row.version(), readiness,
List.copyOf(blockers));
}
@@ -321,9 +336,7 @@ public class InboundPlanService {
}
private static Integer pendingPalletCount(ContainerInboundPlanPreviewResponse.Order order) {
return "RESOURCE_PENDING".equals(order.readiness())
? Objects.requireNonNullElse(order.plannedPalletCount(), 0)
: Objects.requireNonNullElse(order.suggestedPalletCount(), 0);
return order.targetPalletCount();
}
private static String destinationType(InboundPlanOrderMapper.ContainerPreviewOrderRow row) {
@@ -842,7 +855,9 @@ public class InboundPlanService {
InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE"));
if (plan == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
if ("COMPLETED".equals(plan.getStatus())) throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed plan cannot add pallets");
if (List.of("COMPLETED", "PENDING_RECONCILIATION").contains(plan.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed plan cannot add pallets");
}
List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getPlanId, plan.getId()));
boolean destinationExists = lines.stream().anyMatch(line -> request.destinationWarehouseId().equals(line.getDestinationWarehouseId()));
@@ -998,6 +1013,11 @@ public class InboundPlanService {
}
WarehouseResourceGateway.WarehouseReference authoritativeWarehouse = resolvedWarehouse == null
? requiredReceivingWarehouse(receivingWarehouse.overseasWarehouseId()) : resolvedWarehouse;
InboundPlanOrderMapper.DetailOrderSnapshot sourceOrder = plan.getSourceOrderId() == null ? null
: orderMapper.findDetailOrder(plan.getCustomerId(), plan.getSourceOrderId());
if (plan.getSourceOrderId() != null && sourceOrder == null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "入库计划关联订单不存在");
}
List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getPlanId, plan.getId()).orderByAsc(InboundPlanLine::getId));
List<InboundPlanPallet> pallets = palletMapper.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
@@ -1006,8 +1026,39 @@ public class InboundPlanService {
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, plan.getId())
.orderByAsc(InboundPlanPalletAllocation::getId));
List<Long> referencedPalletIds = allocations.stream()
.map(InboundPlanPalletAllocation::getPalletId)
.filter(Objects::nonNull)
.distinct()
.toList();
Map<Long, InboundPlanPallet> referencedPallets = referencedPalletIds.isEmpty()
? Map.of()
: palletMapper.selectBatchIds(referencedPalletIds).stream()
.collect(Collectors.toMap(InboundPlanPallet::getId, pallet -> pallet));
if (referencedPallets.size() != referencedPalletIds.size()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "入库计划分配引用的托盘不存在");
}
List<Long> externalOwnerPlanIds = referencedPallets.values().stream()
.map(InboundPlanPallet::getPlanId)
.filter(Objects::nonNull)
.filter(ownerPlanId -> !Objects.equals(ownerPlanId, plan.getId()))
.distinct()
.toList();
Map<Long, String> palletOwnerPlanNos = new HashMap<>();
palletOwnerPlanNos.put(plan.getId(), plan.getPlanNo());
if (!externalOwnerPlanIds.isEmpty()) {
palletOwnerPlanNos.putAll(planMapper.selectBatchIds(externalOwnerPlanIds).stream()
.collect(Collectors.toMap(InboundPlan::getId, InboundPlan::getPlanNo)));
}
if (referencedPallets.values().stream().anyMatch(pallet ->
pallet.getPlanId() == null || !palletOwnerPlanNos.containsKey(pallet.getPlanId()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "入库计划分配引用的托盘所属计划不存在");
}
return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getSourceOrderId(),
plan.getContainerNo(), authoritativeWarehouse.warehouseCode(), authoritativeWarehouse.warehouseName(),
sourceOrder == null ? null : sourceOrder.orderNo(),
sourceOrder == null ? null : sourceOrder.customerName(),
sourceOrder == null ? null : sourceOrder.orderType(), plan.getContainerNo(),
authoritativeWarehouse.warehouseCode(), authoritativeWarehouse.warehouseName(),
plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), plan.getTotalVolumeCbm(),
lines.stream().mapToInt(InboundPlanLine::getRequiredPackageCount).sum(),
allocations.stream().mapToInt(InboundPlanPalletAllocation::getPickedPackageCount).sum(),
@@ -1020,12 +1071,18 @@ public class InboundPlanService {
pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId(),
pallet.getPalletType(), pallet.getAssignmentStatus(), pallet.getLockedAt(), pallet.getActualVolumeCbm(),
pallet.getActualPackageCount())).toList(),
allocations.stream().map(item -> new InboundPlanResponse.Allocation(item.getLineId(), item.getPalletId(),
allocations.stream().map(item -> {
InboundPlanPallet referencedPallet = referencedPallets.get(item.getPalletId());
return new InboundPlanResponse.Allocation(item.getLineId(), item.getPalletId(),
referencedPallet.getPalletNo(), palletOwnerPlanNos.get(referencedPallet.getPlanId()),
Objects.equals(referencedPallet.getPlanId(), plan.getId()),
item.getAllocatedVolumeCbm(), item.getPlannedPackageCount(), item.getCountedPackageCount(),
item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion())).toList(),
item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion());
}).toList(),
plan.getVersion() == null ? 0 : plan.getVersion(), plan.getPlannedArrivalDate(), plan.getPalletType(),
Boolean.TRUE.equals(plan.getAssignedFlag()), Boolean.TRUE.equals(plan.getMergeRequired()),
Boolean.TRUE.equals(plan.getUrgentFlag()), plan.getCreationMode(), plan.getPlanRemark());
Boolean.TRUE.equals(plan.getUrgentFlag()), plan.getCreationMode(), plan.getPlanRemark(),
plan.getCreatedAt(), plan.getUpdatedAt());
}
private WarehouseResourceGateway.WarehouseReference requiredReceivingWarehouse(String warehouseId) {
@@ -23,8 +23,8 @@ public interface InventoryCommandMapper {
@Param("balanceId") Long balanceId);
@Select("SELECT COALESCE(SUM(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages),0) " +
"FROM ll_inventory_position WHERE customer_id=#{customerId} AND pallet_id=#{palletId}")
long palletPackages(@Param("customerId") String customerId, @Param("palletId") Long palletId);
"FROM ll_inventory_position WHERE pallet_id=#{palletId}")
long palletPackages(@Param("palletId") Long palletId);
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " +
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} " +
@@ -239,7 +239,7 @@ public class InventoryCommandService {
balance.setPickedPackages(value(balance.getPickedPackages()) - request.packages());
touch(balance);
balances.updateById(balance);
boolean palletEmpty = commands.palletPackages(customerId, position.getPalletId()) == 0;
boolean palletEmpty = commands.palletPackages(position.getPalletId()) == 0;
if (palletEmpty) warehouseGateway.scrapPallet(customerId, position.getPalletId());
ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), "SHIP",
"PICKED", "SHIPPED", before, -request.packages(), before - request.packages(),
@@ -417,6 +417,9 @@ public class OverseasProcurementService implements WalletResultHandler {
} catch (NumberFormatException exception) {
throw badRequest("purchaseWarehouseId must be a numeric overseas partner warehouse ID");
}
// Keep the warehouse snapshot already frozen on a historical order. A later master-data
// deactivation must not block unrelated edits such as recording outbound details.
if (Long.toString(numericId).equals(order.getPurchaseWarehouseId())) return;
var option = warehouseDirectory.required(numericId);
order.setPurchaseWarehouseId(option.warehouseId());
order.setPurchaseWarehouseName(option.warehouseName());
@@ -267,7 +267,7 @@ public class RelabelApplicationService implements WalletResultHandler {
if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required");
boolean requiresOutbound = request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item)));
String deliveryMethod = trim(request.deliveryMethod());
if (requiresOutbound && !DELIVERY_METHODS.contains(deliveryMethod)) {
if (requiresOutbound && (!StringUtils.hasText(deliveryMethod) || !DELIVERY_METHODS.contains(deliveryMethod))) {
throw badRequest("Delivery method must be TRUCK, PICKUP or EXPRESS when carton labels are changed");
}
if (!requiresOutbound && StringUtils.hasText(deliveryMethod)) {
@@ -37,6 +37,13 @@ public class MerchantReturnInventoryController {
return ApiResponse.success(service.summaryPage(customer(user), request));
}
@GetMapping("/cartons")
@PreAuthorize("hasAuthority('" + LIST_PERMISSION + "')")
public ApiResponse<PageResult<ReturnInventoryResponses.CartonSummary>> cartons(@AuthenticationPrincipal AuthenticatedUser user,
@Valid ReturnInventoryRequests.Query request) {
return ApiResponse.success(service.cartonPage(customer(user), request));
}
@GetMapping("/cartons/{cartonMark}")
@PreAuthorize("hasAuthority('" + LIST_PERMISSION + "')")
public ApiResponse<ReturnInventoryResponses.CartonDetail> carton(@AuthenticationPrincipal AuthenticatedUser user,
@@ -7,6 +7,11 @@ import java.util.List;
public final class ReturnInventoryResponses {
private ReturnInventoryResponses() { }
public record CartonSummary(String cartonMark, String overseasWarehouseId, String sourceBatchNo,
String sourceReturnNo, String status, int skuTypes, long totalQuantity,
BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight,
LocalDateTime createdAt) { }
public record InventoryItem(String overseasWarehouseId, String cartonMark, String sourceBatchNo, String status, String skuCode,
String productNameZh, long quantity, int cartonSkuTypes, long cartonTotalQuantity,
LocalDateTime receivedAt) { }
@@ -6,6 +6,7 @@ import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@@ -20,6 +21,12 @@ public interface ReturnInventoryBalanceMapper extends BaseMapper<ReturnInventory
@Param("totalQuantity") long totalQuantity, @Param("availableQuantity") long availableQuantity,
@Param("cartons") long cartons);
@Update("UPDATE ll_return_inventory_balance SET carton_quantity=carton_quantity+#{cartons},updated_at=NOW(3) " +
"WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND sku_code=#{skuCode} " +
"AND carton_quantity+#{cartons}>=0")
int adjustCartonQuantity(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode, @Param("cartons") long cartons);
@Select("<script>SELECT COUNT(*) FROM ll_return_inventory_balance WHERE customer_id=#{customerId} " +
"<if test='warehouseId != null and warehouseId != \"\"'>AND overseas_warehouse_id=#{warehouseId} </if>" +
"<if test='skuCode != null and skuCode != \"\"'>AND sku_code LIKE CONCAT('%',#{skuCode},'%') </if></script>")
@@ -9,6 +9,21 @@ import java.util.List;
@Mapper
public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryCarton> {
String MERCHANT_FILTER = " FROM ll_return_inventory_carton c WHERE c.customer_id=#{customerId} " +
"<if test='query.overseasWarehouseId != null and query.overseasWarehouseId != \"\"'>AND c.overseas_warehouse_id=#{query.overseasWarehouseId} </if>" +
"<if test='query.cartonMark != null and query.cartonMark != \"\"'>AND c.carton_mark LIKE CONCAT('%',#{query.cartonMark},'%') </if>" +
"<if test='query.returnNo != null and query.returnNo != \"\"'>AND c.source_return_no LIKE CONCAT('%',#{query.returnNo},'%') </if>" +
"<if test='query.status != null and query.status != \"\"'>AND c.status=#{query.status} </if>" +
"<if test='query.skuCode != null and query.skuCode != \"\"'>AND EXISTS(SELECT 1 FROM ll_return_inventory_carton_item i WHERE i.carton_id=c.id AND i.sku_code LIKE CONCAT('%',#{query.skuCode},'%')) </if>";
@Select("<script>SELECT COUNT(*)" + MERCHANT_FILTER + "</script>")
long merchantCount(@Param("customerId") String customerId,
@Param("query") com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests.Query query);
@Select("<script>SELECT c.*" + MERCHANT_FILTER + "ORDER BY c.created_at DESC,c.id DESC LIMIT #{offset},#{query.size}</script>")
List<ReturnInventoryCarton> merchantPage(@Param("customerId") String customerId,
@Param("query") com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests.Query query, @Param("offset") long offset);
@Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND status='AVAILABLE' ORDER BY created_at DESC,id DESC LIMIT 500")
List<ReturnInventoryCarton> findAvailable(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId);
@Select("SELECT * FROM ll_return_inventory_carton WHERE carton_mark=#{cartonMark} LIMIT 1")
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.returnreceipt.service;
import com.google.zxing.oned.Code128Writer;
/** Inline vector bars remain printable even when browser background printing is disabled. */
final class ReturnCartonBarcode {
private static final int QUIET_ZONE = 10;
private ReturnCartonBarcode() { }
static String svg(String cartonMark) {
boolean[] modules = new Code128Writer().encode(cartonMark);
int width = modules.length + QUIET_ZONE * 2;
StringBuilder svg = new StringBuilder("<svg xmlns=\"http://www.w3.org/2000/svg\" role=\"img\" aria-label=\"箱号条码\" viewBox=\"0 0 ")
.append(width).append(" 50\" preserveAspectRatio=\"none\" style=\"display:block;width:100%;height:15mm\">")
.append("<rect width=\"100%\" height=\"100%\" fill=\"white\"/>");
for (int index = 0; index < modules.length;) {
if (!modules[index]) { index++; continue; }
int start = index;
while (index < modules.length && modules[index]) index++;
svg.append("<rect x=\"").append(start + QUIET_ZONE).append("\" y=\"0\" width=\"")
.append(index - start).append("\" height=\"50\" fill=\"black\"/>");
}
return svg.append("</svg>").toString();
}
}
@@ -97,6 +97,19 @@ public class ReturnInventoryService {
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@Transactional(readOnly = true)
public PageResult<ReturnInventoryResponses.CartonSummary> cartonPage(String customerId, ReturnInventoryRequests.Query request) {
required(customerId, "客户身份不能为空");
long total = cartons.merchantCount(customerId, request);
List<ReturnInventoryResponses.CartonSummary> records = total == 0 ? List.of() :
cartons.merchantPage(customerId, request, (request.page() - 1) * request.size()).stream()
.map(carton -> new ReturnInventoryResponses.CartonSummary(carton.getCartonMark(), carton.getOverseasWarehouseId(),
carton.getSourceBatchNo(), carton.getSourceReturnNo(), carton.getStatus(),
carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()),
carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt())).toList();
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@Transactional(readOnly = true)
public PageResult<ReturnInventoryResponses.SummaryItem> summaryPage(String customerId, ReturnInventoryRequests.SummaryQuery request) {
String warehouseId = text(request.overseasWarehouseId());
@@ -241,6 +254,8 @@ public class ReturnInventoryService {
Map<String, Long> remaining = new LinkedHashMap<>(requested);
Map<String, Integer> emptiedBySku = new LinkedHashMap<>();
Map<Long, Map<String, Long>> attachedByOrder = new LinkedHashMap<>();
LinkedHashSet<String> consumedBatchNos = new LinkedHashSet<>();
LinkedHashSet<String> consumedReturnNos = new LinkedHashSet<>();
long total = 0;
for (ReturnInventoryCarton source : sources) {
List<ReturnInventoryCartonItem> sourceItems = cartonItems.listCarton(source.getId());
@@ -249,6 +264,8 @@ public class ReturnInventoryService {
String sku = item.getSkuCode().toUpperCase(java.util.Locale.ROOT);
long take = Math.min(value(remaining.get(sku)), value(item.getQuantity()));
if (take > 0) {
consumedBatchNos.add(required(source.getSourceBatchNo(), "来源签收批次缺失,无法合箱"));
consumedReturnNos.add(required(source.getSourceReturnNo(), "来源退货单缺失,无法合箱"));
remaining.put(sku, remaining.get(sku) - take); total = Math.addExact(total, take);
ReturnOrder owner = sourceOwners.get(source.getId());
if (owner != null) attachedByOrder.computeIfAbsent(owner.getId(), ignored -> new LinkedHashMap<>())
@@ -270,7 +287,7 @@ public class ReturnInventoryService {
long targetId = IdWorker.getId();
ReturnInventoryCarton target = new ReturnInventoryCarton();
target.setId(targetId); target.setCustomerId(customerId); target.setOverseasWarehouseId(sources.get(0).getOverseasWarehouseId());
target.setCartonMark(numbers.next("RETURN_CARTON", target.getOverseasWarehouseId(), targetId)); target.setSourceBatchNo("MERGED"); target.setSourceReturnNo(String.join(",", returnNos)); target.setStatus(AVAILABLE);
target.setCartonMark(numbers.next("RETURN_CARTON", target.getOverseasWarehouseId(), targetId)); target.setSourceBatchNo(String.join(",", consumedBatchNos)); target.setSourceReturnNo(String.join(",", consumedReturnNos)); target.setStatus(AVAILABLE);
target.setSkuTypes(requested.size()); target.setTotalQuantity(total); target.setLength(request.length()); target.setWidth(request.width()); target.setHeight(request.height()); target.setWeight(request.weight()); target.setCreatedAt(now); target.setUpdatedAt(now); cartons.insert(target);
for (Map.Entry<String, Long> entry : requested.entrySet()) {
ReturnInventoryCartonItem targetItem = new ReturnInventoryCartonItem();
@@ -279,8 +296,9 @@ public class ReturnInventoryService {
// A SKU's carton count is the number of available physical cartons containing it:
// remove fully emptied source cartons, then add the one merged target carton.
long cartonDelta = 1L - emptiedBySku.getOrDefault(entry.getKey(), 0);
balances.adjust(IdWorker.getId(), customerId, target.getOverseasWarehouseId(), entry.getKey(), productNames.get(entry.getKey()), 0,
-entry.getValue(), cartonDelta);
if (balances.adjustCartonQuantity(customerId, target.getOverseasWarehouseId(), entry.getKey(), cartonDelta) != 1) {
throw new IllegalStateException("退货合箱库存箱数更新失败: " + entry.getKey());
}
}
attachMergedCarton(target, attachedByOrder, selectedOrders, productNames, now);
operation.setResultCartonId(targetId); operation.setUpdatedAt(now); operations.updateById(operation);
@@ -351,13 +369,14 @@ public class ReturnInventoryService {
for (ReturnInventoryResponses.CartonItem item : items) rows.append("<tr><td>").append(escape(item.skuCode())).append("</td><td>")
.append(escape(item.productNameZh())).append("</td><td class=\"qty\">").append(item.quantity()).append("</td></tr>");
return "<!doctype html><html><head><meta charset=\"utf-8\"><title>退货箱唛</title><style>"
+ "@page{size:100mm 150mm;margin:5mm}body{font-family:Arial,'Microsoft YaHei',sans-serif;color:#111;margin:0}.label{border:2px solid #111;padding:5mm;min-height:136mm}.brand{font-size:16px;font-weight:700}.title{font-size:22px;font-weight:700;margin:3mm 0 5mm}.mark{font:700 18px monospace;letter-spacing:1px;word-break:break-all}.meta{font-size:11px;margin:4mm 0;border-top:1px solid #111;border-bottom:1px solid #111;padding:3mm 0}.barcode{height:11mm;margin:3mm 0;background:repeating-linear-gradient(90deg,#111 0 2px,#fff 2px 4px,#111 4px 5px,#fff 5px 8px)}table{width:100%;border-collapse:collapse;font-size:11px}th,td{border:1px solid #111;padding:2mm;text-align:left}.qty{text-align:right;font-weight:700}.total{font-size:14px;font-weight:700;text-align:right;margin-top:4mm}</style></head><body><div class=\"label\"><div class=\"brand\">BAIYUE OVERSEAS WAREHOUSE</div><div class=\"title\">RETURN CARTON LABEL / 退货箱唛</div><div class=\"mark\">"
+ escape(carton.getCartonMark()) + "</div><div class=\"barcode\" aria-label=\"" + escape(carton.getCartonMark()) + "\"></div>"
+ "<div class=\"meta\">仓库:" + escape(carton.getOverseasWarehouseId()) + "<br>客户:" + escape(carton.getCustomerId())
+ "<br>箱规:" + escape(String.valueOf(carton.getLength())) + " × " + escape(String.valueOf(carton.getWidth())) + " × " + escape(String.valueOf(carton.getHeight())) + " cm / " + escape(String.valueOf(carton.getWeight())) + " kg"
+ "@page{size:100mm 150mm;margin:5mm}body{font-family:Arial,'Microsoft YaHei',sans-serif;color:#111;margin:0}.label{box-sizing:border-box;width:90mm;border:2px solid #111;padding:3mm;min-height:138mm}.brand{font-size:12px;font-weight:700}.title{font-size:16px;font-weight:700;margin:3mm 0}.mark{font:700 16px monospace;word-break:break-all}.meta{font-size:11px;margin:3mm 0;border-top:1px solid #111;border-bottom:1px solid #111;padding:2mm 0;overflow-wrap:anywhere}.barcode{margin:3mm 0}table{width:100%;border-collapse:collapse;font-size:11px;table-layout:fixed}th,td{border:1px solid #111;padding:1mm;text-align:left;overflow-wrap:anywhere}tr{break-inside:avoid}.qty{text-align:right;font-weight:700}.total{font-size:14px;font-weight:700;text-align:right;margin-top:3mm}</style></head><body><div class=\"label\"><div class=\"brand\">BAIYUE OVERSEAS WAREHOUSE</div><div class=\"title\">RETURN CARTON LABEL / 退货箱唛</div><div class=\"mark\">"
+ escape(carton.getCartonMark()) + "</div><div class=\"barcode\">" + ReturnCartonBarcode.svg(carton.getCartonMark()) + "</div>"
+ "<div class=\"meta\">来源退货单:" + escape(carton.getSourceReturnNo())
+ "<br>箱规:" + dimension(carton.getLength()) + " × " + dimension(carton.getWidth()) + " × " + dimension(carton.getHeight()) + " cm / " + dimension(carton.getWeight()) + " kg"
+ "<br>签收批次:" + escape(carton.getSourceBatchNo()) + "</div><table><thead><tr><th>SKU</th><th>品名</th><th>数量</th></tr></thead><tbody>"
+ rows + "</tbody></table><div class=\"total\">合计:" + carton.getTotalQuantity() + " 件 / " + carton.getSkuTypes() + " 种 SKU</div></div></body></html>";
}
private static String dimension(BigDecimal value) { return value == null ? "—" : value.stripTrailingZeros().toPlainString(); }
private String hash(Object request) { try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(String.valueOf(request).getBytes(StandardCharsets.UTF_8))); }
catch (Exception exception) { throw new IllegalStateException("无法生成请求哈希", exception); } }
@@ -118,12 +118,14 @@ public class ReturnReceiptService {
tail.setId(IdWorker.getId());
tail.setOrderNo(numbers.next("RETURN_RELABEL", returnOrder.getOverseasWarehouseId(), tail.getId()));
tail.setCustomerId(returnOrder.getCustomerId()); tail.setOrderType("RETURN_RELABEL");
tail.setSourceRegion("CUSTOMER"); tail.setSourceOrderId(String.valueOf(returnOrder.getId()));
tail.setSourceRegion("CUSTOMER"); tail.setSourceInboundId(returnOrder.getReturnNo());
tail.setSourceOrderId(String.valueOf(returnOrder.getId()));
tail.setSourceOrderNo(returnOrder.getReturnNo()); tail.setOverseasWarehouseId(returnOrder.getOverseasWarehouseId());
tail.setCurrentStatus("PENDING_ASSIGNMENT"); tail.setSubOrderStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "RETURN_RELABEL:PENDING_BINDING" : "RETURN_RELABEL:NOT_REQUIRED");
tail.setRelabelStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "PENDING_BINDING" : "NOT_REQUIRED");
tail.setCustomerRemark(returnOrder.getRemark()); tail.setSourceEventId("RETURN_ORDER:" + returnOrder.getReturnNo());
tail.setSourceLoadingPlanId("RETURN_RELABEL"); tail.setCreatedAt(now); tail.setUpdatedAt(now);
tail.setSourcePayloadHash(returnOrder.getRequestHash());
lastlegOrders.insert(tail);
returnOrder.setLastlegOrderId(tail.getId()); returnOrder.setLastlegOrderNo(tail.getOrderNo());
returnOrder.setRelabelStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "PENDING_BINDING" : "NOT_REQUIRED");
@@ -31,6 +31,8 @@ import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper;
import com.baiyue.lastleg.model.LastlegOrderType;
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@@ -75,6 +77,7 @@ public class LastlegOrderService {
@Resource private InboundOrderDetainedOutboxService detainedOutbox;
@Resource private ReceivingWarehouseOperationMapper receivingWarehouseOperations;
@Resource private WarehouseResourceGateway warehouseResourceGateway;
@Resource private ContainerShippingGateMapper containerShippingGates;
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
return page(customerId, query, null);
@@ -85,7 +88,9 @@ public class LastlegOrderService {
new Page<>(query.page(), query.size()), filter(customerId, query, relabelNo).orderByDesc(LastlegOrder::getCreatedAt));
Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> mergeRelations = palletMergeRelations(page.getRecords());
Map<Long, String> dispatchStatuses = dispatchStatuses(page.getRecords());
Map<String, String> containerNodes = containerNodes(page.getRecords());
return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()),
StringUtils.hasText(order.getSourceContainerBookingId()) ? containerNodes.get(order.getSourceContainerBookingId()) : null,
dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"))).toList(),
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
@@ -269,7 +274,7 @@ public class LastlegOrderService {
value.getNodeCode(), value.getNodeAt(), value.getSource()))
.sorted(java.util.Comparator.comparingInt(value -> MANUAL_NODE_CODES.indexOf(value.nodeCode()))).toList();
return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId()),
dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED")), manualNodes, cargo, history.stream()
containerNode(order), dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED")), manualNodes, cargo, history.stream()
.map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(),
value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream()
.map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(),
@@ -488,20 +493,22 @@ public class LastlegOrderService {
public void writeExportCsv(String customerId, LastlegOrderRequests.Query query, OutputStream output)
throws IOException {
output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8));
output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜节点,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8));
final int batchSize = 1000;
long page = 1;
while (true) {
List<LastlegOrder> orders = orderMapper.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, batchSize),
filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId)).getRecords();
if (orders.isEmpty()) break;
Map<String, String> containerNodes = containerNodes(orders);
for (LastlegOrder order : orders) {
String line = String.join(",", List.of(
csv(order.getId()), csv(order.getOrderNo()), csv(LastlegOrderType.labelOf(order.getOrderType())),
csv(displayInboundCode(order)),
csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()),
csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()),
csv(order.getContainerNo()), csv(order.getContainerStatus()), csv(order.getAbnormal()), csv(order.getDetained()),
csv(order.getContainerNo()), csv(StringUtils.hasText(order.getSourceContainerBookingId())
? containerNodes.get(order.getSourceContainerBookingId()) : null), csv(order.getAbnormal()), csv(order.getDetained()),
csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getVolumeCbm()), csv(order.getProductQuantity()),
csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()),
csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()),
@@ -571,7 +578,7 @@ public class LastlegOrderService {
return result;
}
private Map<Long, String> dispatchStatuses(List<LastlegOrder> orders) {
private Map<Long, String> dispatchStatuses(List<LastlegOrder> orders) {
if (orders.isEmpty()) return Map.of();
List<LastlegOrderMapper.DispatchStatusRow> rows = orderMapper.findDispatchStatuses(
orders.stream().map(LastlegOrder::getId).toList());
@@ -581,13 +588,14 @@ public class LastlegOrderService {
}
private LastlegOrderSummary summary(LastlegOrder o, List<LastlegOrderMapper.PalletMergeRelationRow> relations,
String dispatchStatus) {
String containerNode, String dispatchStatus) {
List<LastlegOrderMapper.PalletMergeRelationRow> related = relations == null ? List.of() : relations;
return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(),
LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(),
displayInboundCode(o), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(),
o.getOverseasWarehouseCode(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(),
containerNode,
o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(),
Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER",
o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
@@ -599,6 +607,25 @@ public class LastlegOrderService {
o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt());
}
private String containerNode(LastlegOrder order) {
if (!StringUtils.hasText(order.getSourceContainerBookingId())) return null;
ContainerShippingGate gate = containerShippingGates.selectById(order.getSourceContainerBookingId());
return gate == null ? null : gate.getStandardNode();
}
private Map<String, String> containerNodes(List<LastlegOrder> orders) {
List<String> bookingIds = orders.stream().map(LastlegOrder::getSourceContainerBookingId)
.filter(StringUtils::hasText).distinct().toList();
if (bookingIds.isEmpty()) return Map.of();
Map<String, String> result = new LinkedHashMap<>();
containerShippingGates.selectBatchIds(bookingIds).forEach(gate -> {
if (StringUtils.hasText(gate.getStandardNode())) {
result.put(gate.getContainerBookingId(), gate.getStandardNode());
}
});
return result;
}
private static String displayInboundCode(LastlegOrder order) {
String orderType = order.getOrderType();
return "RELABEL_APPLICATION".equals(orderType) || "LOCAL_LOOSEBOARD".equals(orderType)
@@ -253,7 +253,7 @@ public class LoadingCompletedConsumer {
order.setReceivingWarehouseVersion(0);
order.setFbaWarehouseCode("FBA".equals(destinationType) ? source.destinationWarehouseCode() : null);
order.setContainerNo(containerNo);
order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false);
order.setAbnormal(false); order.setDetained(false);
order.setPackageCount(source.packageCount());
order.setPalletCount("ONE_CLICK_DROPSHIP".equals(orderType)
? dropshipPallets.size() : Objects.requireNonNullElse(source.palletCount(), 0));
@@ -459,7 +459,10 @@ public class LoadingCompletedConsumer {
private List<DropshipRequests.Pallet> pallets(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) {
if (source.pallets() != null && !source.pallets().isEmpty()) {
return source.pallets().stream().map(pallet -> {
List<DropshipRequests.Pallet> result = new ArrayList<>();
JsonNode rawPallets = rawSource.path("pallets");
for (int palletIndex = 0; palletIndex < source.pallets().size(); palletIndex++) {
FirstLegLoadingPlanCompletedV1.PalletSnapshot pallet = source.pallets().get(palletIndex);
Map<String, DropshipRequests.SkuPackages> inventorySkus = new java.util.LinkedHashMap<>();
for (var sku : pallet.skuPackages()) {
inventorySkus.merge(sku.skuCode(), new DropshipRequests.SkuPackages(sku.skuCode(),
@@ -470,8 +473,13 @@ public class LoadingCompletedConsumer {
Math.addExact(left.productQuantity(), right.productQuantity()), List.copyOf(marks));
});
}
return new DropshipRequests.Pallet(pallet.palletNo(), List.copyOf(inventorySkus.values()));
}).toList();
JsonNode rawPallet = rawPallets.isArray() && palletIndex < rawPallets.size()
? rawPallets.get(palletIndex) : objectMapper.createObjectNode();
result.add(new DropshipRequests.Pallet(pallet.palletNo(), List.copyOf(inventorySkus.values()),
positiveManifest(rawPallet, "manifestVersion"), positiveManifest(rawPallet, "manifestPackageCount"),
positiveManifest(rawPallet, "manifestFragmentCount"), requiredManifestHash(rawPallet)));
}
return List.copyOf(result);
}
return legacyPallets(rawSource);
}
@@ -484,7 +492,9 @@ public class LoadingCompletedConsumer {
String palletNo = pallet.path("palletNo").asText(pallet.path("sourcePalletNo").asText(null));
List<DropshipRequests.SkuPackages> skuRows = skuPackages(pallet);
if (palletNo != null && !palletNo.isBlank() && !skuRows.isEmpty()) {
result.add(new DropshipRequests.Pallet(palletNo, skuRows));
result.add(new DropshipRequests.Pallet(palletNo, skuRows,
positiveManifest(pallet, "manifestVersion"), positiveManifest(pallet, "manifestPackageCount"),
positiveManifest(pallet, "manifestFragmentCount"), requiredManifestHash(pallet)));
}
}
return result;
@@ -492,11 +502,27 @@ public class LoadingCompletedConsumer {
String palletNo = source.path("palletNo").asText(source.path("sourcePalletNo").asText(null));
List<DropshipRequests.SkuPackages> skuRows = skuPackages(source);
if (palletNo != null && !palletNo.isBlank() && !skuRows.isEmpty()) {
result.add(new DropshipRequests.Pallet(palletNo, skuRows));
result.add(new DropshipRequests.Pallet(palletNo, skuRows,
positiveManifest(source, "manifestVersion"), positiveManifest(source, "manifestPackageCount"),
positiveManifest(source, "manifestFragmentCount"), requiredManifestHash(source)));
}
return result;
}
private int positiveManifest(JsonNode source, String field) {
int value = source.path(field).asInt(0);
if (value <= 0) throw new IllegalArgumentException("One-click dropship pallet is missing " + field);
return value;
}
private String requiredManifestHash(JsonNode source) {
String value = source.path("manifestHash").asText(null);
if (value == null || !value.matches("(?i)[0-9a-f]{64}")) {
throw new IllegalArgumentException("One-click dropship pallet manifestHash is invalid");
}
return value.toLowerCase(java.util.Locale.ROOT);
}
private List<DropshipRequests.SkuPackages> skuPackages(JsonNode source) {
List<DropshipRequests.SkuPackages> result = new ArrayList<>();
JsonNode nodes = source.path("skuPackages");
@@ -67,6 +67,8 @@ baiyue:
connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s}
clients:
print:
url: ${PRINT_SERVICE_URL:}
domestic-warehouse:
url: ${DOMESTIC_WAREHOUSE_URL:}
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
@@ -67,7 +67,6 @@ CREATE TABLE IF NOT EXISTS ll_order (
transfer_warehouse_name VARCHAR(255) COMMENT '转入目的仓名称',
fba_warehouse_code VARCHAR(128) COMMENT 'FBA 仓库编码',
container_no VARCHAR(64) COMMENT '集装箱号',
container_status VARCHAR(32) DEFAULT '待转柜' COMMENT '货柜状态',
abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是',
detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是',
hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT '当前暂扣是否计费:0否1是',
@@ -444,7 +443,6 @@ ALTER TABLE ll_order
MODIFY COLUMN receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT '柜级接柜仓分配乐观锁版本',
MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA 仓库编码',
MODIFY COLUMN container_no VARCHAR(64) NULL COMMENT '集装箱号',
MODIFY COLUMN container_status VARCHAR(32) NULL DEFAULT '待转柜' COMMENT '货柜状态',
MODIFY COLUMN abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是',
MODIFY COLUMN detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是',
MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数',
@@ -660,8 +658,9 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet (
capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量,固定为2立方米',
allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)',
sequence_no INT NOT NULL COMMENT '计划内托盘序号',
version INT NOT NULL DEFAULT 0 COMMENT '收货累计值乐观锁版本',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_inbound_pallet_no (pallet_no),
KEY idx_ll_inbound_pallet_no (pallet_no),
UNIQUE KEY uk_ll_inbound_pallet_sequence (plan_id, sequence_no),
KEY idx_ll_inbound_pallet_plan (plan_id),
KEY idx_ll_inbound_pallet_customer_plan (customer_id, plan_id)
@@ -765,6 +764,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_package_count INT NOT NULL DEFAULT 0 COMMENT ''实收箱数'''));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''收货累计值乐观锁版本'''));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='planned_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN planned_package_count INT NOT NULL DEFAULT 0 COMMENT ''计划分配箱数'''));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='counted_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN counted_package_count INT NOT NULL DEFAULT 0 COMMENT ''清点箱数'''));
@@ -996,7 +997,7 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '平台预分配并由操作员Token匹配的海外接柜仓ID', overseas_warehouse_code VARCHAR(128) NOT NULL COMMENT '海外接柜仓业务编码快照', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外接柜仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识',
operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED、COMPLETED_WITH_DIFFERENCE',
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间',
signed_at DATETIME(3) NULL COMMENT '签收时间', receiving_at DATETIME(3) NULL COMMENT '首次收货时间',
completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -1019,6 +1020,16 @@ CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt (
KEY idx_ll_receiving_sku_plan (customer_id,plan_id,line_id)
) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度';
CREATE TABLE IF NOT EXISTS ll_receiving_fba_pallet_count (
id BIGINT NOT NULL COMMENT 'FBA托盘现场累计箱数ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID',
group_key VARCHAR(255) NOT NULL COMMENT '柜号与FBA仓组成的收货组键', pallet_code VARCHAR(128) NOT NULL COMMENT '现有FBA托盘码',
actual_packages INT NOT NULL COMMENT '该托现场累计实物箱数', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_receiving_fba_count(session_id,group_key,pallet_code),
KEY idx_ll_receiving_fba_group(session_id,group_key)
) ENGINE=InnoDB COMMENT='海外PDA普通FBA托盘现场累计与超收标记';
CREATE TABLE IF NOT EXISTS ll_receiving_container_operation (
id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID',
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',
@@ -1734,6 +1745,10 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item (
KEY idx_ll_relabel_item_destination (customer_id,destination_warehouse_id,application_id)
) ENGINE=InnoDB COMMENT='换标货物冻结明细';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='destination_fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码(与海外操作仓分离)''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inventory_balance_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inventory_balance_id BIGINT NULL COMMENT ''原库存余额ID(预入仓换标任务可为空)''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inventory_position_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inventory_position_id BIGINT NULL COMMENT ''原库存库位ID(预入仓换标任务可为空)''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_order_id BIGINT NULL COMMENT ''原入仓单ID(预入仓换标任务可为空)''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name IN ('shipment_no','destination_fba_warehouse_code','source_inventory_balance_id','source_inventory_position_id','source_inbound_order_id') AND is_nullable='NO'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT ''货件编号(无FBA去向时为空)'', MODIFY COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码(与海外操作仓分离)'', MODIFY COLUMN source_inventory_balance_id BIGINT NULL COMMENT ''原库存余额ID(预入仓换标任务可为空)'', MODIFY COLUMN source_inventory_position_id BIGINT NULL COMMENT ''原库存库位ID(预入仓换标任务可为空)'', MODIFY COLUMN source_inbound_order_id BIGINT NULL COMMENT ''原入仓单ID(预入仓换标任务可为空)''', 'SELECT 1'));
PREPARE relabel_pre_inbound_stmt FROM @ddl; EXECUTE relabel_pre_inbound_stmt; DEALLOCATE PREPARE relabel_pre_inbound_stmt;
@@ -1844,11 +1859,14 @@ CREATE TABLE IF NOT EXISTS ll_dropship_order_profile (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID',
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号',
source_pallet_no VARCHAR(128) NOT NULL COMMENT '来源托盘号快照',
manifest_version INT NOT NULL COMMENT '整托清单版本', manifest_package_count INT NOT NULL COMMENT '整托总箱数',
manifest_fragment_count INT NOT NULL COMMENT '整托客户分片总数', manifest_hash CHAR(64) NOT NULL COMMENT '整托完整清单摘要',
inventory_position_id BIGINT NULL COMMENT '库存仓位记录ID', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态',
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no),
UNIQUE KEY uk_ll_dropship_profile_key(customer_id,idempotency_key), KEY idx_ll_dropship_profile_order(customer_id,order_id)
UNIQUE KEY uk_ll_dropship_profile_key(customer_id,idempotency_key), KEY idx_ll_dropship_profile_order(customer_id,order_id),
KEY idx_ll_dropship_profile_manifest(source_pallet_no,manifest_hash)
) ENGINE=InnoDB COMMENT='一件代发托盘档案';
CREATE TABLE IF NOT EXISTS ll_dropship_carton (
@@ -2385,10 +2403,12 @@ CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation (
-- 客户退货预报、PDA 签收与仓储费起算批次
CREATE TABLE IF NOT EXISTS ll_return_order (
-- need_relabel is added below for existing deployments
id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', return_no VARCHAR(128) NOT NULL COMMENT '退货订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', warehouse_contact_name VARCHAR(128) NULL COMMENT '中转仓联系人快照', warehouse_contact_phone VARCHAR(64) NULL COMMENT '中转仓联系电话快照', customer_reference_no VARCHAR(128) NULL COMMENT '客户参考号', status VARCHAR(32) NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', expected_sku_types INT NOT NULL COMMENT '预报SKU种类数', expected_quantity BIGINT NOT NULL COMMENT '预报总数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计实收总数量', remark VARCHAR(1000) NULL COMMENT '客户备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求SHA-256', created_by VARCHAR(128) NULL COMMENT '创建操作人ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', return_no VARCHAR(128) NOT NULL COMMENT '退货订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', warehouse_contact_name VARCHAR(128) NULL COMMENT '中转仓联系人快照', warehouse_contact_phone VARCHAR(64) NULL COMMENT '中转仓联系电话快照', customer_reference_no VARCHAR(128) NULL COMMENT '客户参考号', lastleg_order_id BIGINT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NULL COMMENT '尾程订单号', status VARCHAR(32) NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', expected_sku_types INT NOT NULL COMMENT '预报SKU种类数', expected_quantity BIGINT NOT NULL COMMENT '预报总数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计实收总数量', remark VARCHAR(1000) NULL COMMENT '客户备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求SHA-256', created_by VARCHAR(128) 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_order_customer_no(customer_id,return_no), UNIQUE KEY uk_ll_return_order_customer_key(customer_id,idempotency_key), KEY idx_ll_return_order_warehouse_status(overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_order_quantity CHECK(expected_sku_types>0 AND expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity)
) ENGINE=InnoDB COMMENT='客户退货订单及SKU实收汇总';
SET @ll_return_order_need_relabel = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='need_relabel'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN need_relabel TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要换标'' AFTER customer_reference_no')); PREPARE stmt FROM @ll_return_order_need_relabel; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='lastleg_order_id'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_id BIGINT NULL COMMENT ''尾程订单ID'' AFTER customer_reference_no')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='lastleg_order_no'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_no VARCHAR(32) NULL COMMENT ''尾程订单号'' AFTER lastleg_order_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_name'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_name VARCHAR(128) NULL COMMENT ''中转仓联系人快照'' AFTER overseas_warehouse_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_phone'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_phone VARCHAR(64) NULL COMMENT ''中转仓联系电话快照'' AFTER warehouse_contact_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='relabel_application_id'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN relabel_application_id BIGINT NULL COMMENT ''绑定换标申请单ID'' AFTER need_relabel')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -2462,6 +2482,7 @@ PREPARE return_inventory_carton_status_drop_stmt FROM @ddl; EXECUTE return_inven
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'));
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_return_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_return_no VARCHAR(2000) NULL COMMENT ''来源退货单号'' AFTER source_batch_no'));
PREPARE return_inventory_carton_return_no_stmt FROM @ddl; EXECUTE return_inventory_carton_return_no_stmt; DEALLOCATE PREPARE return_inventory_carton_return_no_stmt;
ALTER TABLE ll_return_inventory_carton MODIFY COLUMN source_batch_no TEXT NULL COMMENT '实际消耗来源签收批次号,多个批次逗号分隔', MODIFY COLUMN source_return_no TEXT NULL COMMENT '实际消耗来源退货单号,多个单号逗号分隔';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='fba_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN fba_carton_mark VARCHAR(255) NULL COMMENT ''FBA箱唛'' AFTER carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='destination_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码'' AFTER fba_carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='destination_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称'' AFTER destination_warehouse_code')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -96,24 +96,24 @@ paths:
/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve:
post:
summary: 解析当前柜箱唛
description: 权限 lastleg:pda:operate;FBA 从完整 cartonMark 的第一个 # 前提取固定入仓码,并精确匹配当前锁定柜唯一 FBA line;不要求逐箱 manifest,不校验 SKU 或箱序号。OVERSEAS 必须命中真实 manifest。两者均不返回 SKU,也不登记收货。
description: 权限 lastleg:pda:operate;一件代发扫描 DP 或任一箱码解析整托并校验跨客户完整清单;普通 FBA 扫任一箱码后按当前柜+FBA目的仓聚合所有入仓码;海外仓中转仍逐箱解析。响应不返回客户信息。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [cartonMark], properties: { cartonMark: { type: string } } } } } }
responses: { '200': { description: 箱唛与入仓码信息, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCartonResolvedApiResponse' } } } }, '404': { description: 入仓码未命中当前柜,或 OVERSEAS 箱码未命中真实 manifest }, '409': { description: '缺少 #、空入仓码前缀、跨柜或当前柜多重命中' } }
/api/pda/v1/last-leg/receiving-containers/current/pallets/resolve:
post:
summary: 解析当前柜实物托盘码
description: 权限 lastleg:pda:operate;托盘固定从 Warehouse Overseas 公共资源作用域解析并校验实际仓与目的仓。
description: 权限 lastleg:pda:operate;普通 FBA 只解析当前柜该 FBA 目的仓已经生成的“柜号-FBA仓-序号”托盘,返回版本与该托现场累计箱数。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [palletCode, inboundCode], properties: { palletCode: { type: string }, inboundCode: { type: string } } } } } }
responses: { '200': { description: 可用托盘与目的匹配结果, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingPalletResolvedApiResponse' } } } }, '409': { description: 仓库、目的仓或托盘状态不符 } }
/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk:
post:
summary: FBA 入仓码按箱数收货
description: 权限 lastleg:pda:operate;只允许 destinationType=FBA,OVERSEAS 在服务端硬拒绝;服务端按当前入仓码所属计划的 line.id ASC 和冻结 SKU 明细顺序拆分箱数并记录真实 SKU 收货;不接收 SKU、customerId、planNo、allocationId、warehouseId、locationId。
description: 权限 lastleg:pda:operate;只允许普通 FBA。packageCount 是该托现场累计箱数,服务端按当前柜+FBA目的仓稳定分摊到全部关联入仓码;超出计划部分只记差异,不生成可用库存。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingBulk' } } } }
responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: 海外仓批量、超收或托盘不匹配 } }
responses: { '200': { description: 本次与累计实收进度,超收返回 DIFFERENCE_RECORDED, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: 非普通FBA、累计数回退、版本变化或托盘不匹配 } }
/api/pda/v1/last-leg/receiving-containers/current/receipts/carton:
post:
summary: 海外仓入仓码逐箱收货
@@ -124,17 +124,17 @@ paths:
/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet:
post:
summary: 一件代发按完整来源托盘收货
description: 权限 lastleg:pda:operate;只允许 ONE_CLICK_DROPSHIP,扫描国内封托后冻结的来源托盘号,一次接收该托全部箱和 SKU 快照;不允许逐箱或拆分接收。
description: 权限 lastleg:pda:operate;只允许 ONE_CLICK_DROPSHIP,扫描 DP 或该托任一箱码,一次接收跨客户完整 DP;所有客户事件分片清单一致且齐全后才允许收货。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingWholePallet' } } } }
responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '404': { description: 来源托盘不属于当前柜 }, '409': { description: 非一件代发、来源托盘重复或已操作 } }
/api/pda/v1/last-leg/receiving-containers/current/complete:
post:
summary: 完成当前柜收货并入库存
description: 权限 lastleg:pda:operate;仅柜内每个入仓码实收总数精确相等后完成,库存、计划、尾程状态与卸柜 Outbox 同事务落地。
description: 权限 lastleg:pda:operate;无差异时正常入库;allowDifference=true 时允许短少或超收现场结束,差异计划进入 PENDING_RECONCILIATION 且不生成客户可用库存。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } }
responses: { '200': { description: COMPLETED 柜号会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 数量未核平或状态冲突 } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingComplete' } } } }
responses: { '200': { description: COMPLETED 或 COMPLETED_WITH_DIFFERENCE 柜号会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 未确认差异或状态冲突 } }
/api/pda/v1/last-leg/inventory/resolve:
post:
summary: PDA按SKU、箱唛或入仓码扫码查询库存
@@ -169,7 +169,7 @@ paths:
/api/platform/v1/last-leg/orders:
get:
summary: 分页查询尾程订单
description: 权限 platform:overseas-warehouse:last-mile-order:list;平台管理员省略 customerId 时查询全部客户。
description: 权限 platform:overseas-warehouse:last-mile-order:list;平台管理员省略 customerId 时查询全部客户。containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
@@ -190,7 +190,7 @@ paths:
/api/platform/v1/last-leg/orders/{id}:
get:
summary: 查询尾程订单详情
description: 权限 platform:overseas-warehouse:last-mile-order:list;返回订单、货物、状态历史、费用明细和操作记录。
description: 权限 platform:overseas-warehouse:last-mile-order:list;返回订单、货物、状态历史、费用明细和操作记录。订单中的 containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁。
security: [{ bearerAuth: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
@@ -513,6 +513,21 @@ paths:
security: [{ bearerAuth: [] }]
parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 取消成功或已取消 }, '404': { description: 排车单不存在 }, '409': { description: 排车已开始执行 } }
/api/platform/v1/last-leg/dispatches/{dispatchNo}/prints/bol:
get:
summary: 生成排车 BOL PDF
description: 权限 platform:overseas-warehouse:dispatch:list;服务端按目的 FBA 仓分组并分页生成固定 A4 PDF,浏览器不直接访问内部打印服务。
security: [{ bearerAuth: [] }]
parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }]
responses:
'200':
description: BOL PDF
headers:
Content-Disposition: { schema: { type: string } }
X-Print-Page-Count: { schema: { type: integer, minimum: 1 } }
content: { application/pdf: { schema: { type: string, format: binary } } }
'404': { description: 排车单不存在 }
'422': { description: 排车单没有可打印的 BOL 明细 }
/api/platform/v1/last-leg/dispatches/candidates:
get:
summary: 查询智能排车候选托盘
@@ -1407,6 +1422,21 @@ paths:
- { name: overseasWarehouseId, in: query, schema: { type: string, maxLength: 128 } }
- { name: skuCode, in: query, schema: { type: string, maxLength: 128 } }
responses: { '200': { description: 当前客户 SKU 汇总库存分页 } }
/api/merchant/v1/last-leg/return-inventory/cartons:
get:
summary: 客户退货箱号分页
description: 需要 merchant:overseas-warehouse:return-inventory:list。客户范围仅取登录身份且精确匹配;每个实物箱一条记录,SKU 筛选使用存在性条件,不重复箱号或放大总箱数。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: overseasWarehouseId, in: query, schema: { type: string } }
- { name: skuCode, in: query, schema: { type: string } }
- { name: cartonMark, in: query, schema: { type: string } }
- { name: returnNo, in: query, schema: { type: string } }
- { name: status, in: query, schema: { type: string } }
responses:
'200': { description: 分页 records 每箱一行,包含 cartonMark、overseasWarehouseId、sourceBatchNo、sourceReturnNo、status、skuTypes、totalQuantity、length、width、height、weight、createdAt;total 为实际箱数 }
/api/merchant/v1/last-leg/return-inventory/cartons/{cartonMark}:
get:
summary: 客户查询退货箱明细
@@ -1810,6 +1840,12 @@ components:
type: object
required: [idempotencyKey]
properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } }
ReceivingComplete:
type: object
required: [allowDifference, idempotencyKey]
properties:
allowDifference: { type: boolean }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
ReceivingSign:
type: object
required: [photos, idempotencyKey]
@@ -1836,6 +1872,7 @@ components:
inboundCode: { type: string, minLength: 1 }
palletCode: { type: string, minLength: 1 }
packageCount: { type: integer, minimum: 1 }
expectedPalletVersion: { type: integer, minimum: 0, nullable: true }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
ReceivingCarton:
type: object
@@ -1912,13 +1949,13 @@ components:
currentAccount: { type: boolean }
ReceivingSession:
type: object
required: [containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, version, photos, totalPackages, receivedPackages, pendingPackages, inbounds]
required: [containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, version, photos, totalPackages, receivedPackages, pendingPackages, hasDifference, inbounds]
properties:
containerNo: { type: string }
overseasWarehouseId: { type: string, pattern: '^\d+$', description: 平台预分配并与PDA Token匹配的海外接柜仓ID }
overseasWarehouseCode: { type: string, description: 海外接柜仓业务编码快照 }
overseasWarehouseName: { type: string, description: Warehouse Overseas服务校验后的共享实际海外收货仓名称快照 }
status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] }
status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED, COMPLETED_WITH_DIFFERENCE] }
operatorName: { type: string, nullable: true }
version: { type: integer }
photos: { type: array, maxItems: 3, items: { $ref: '#/components/schemas/ReceivingSignedSessionPhoto' } }
@@ -1929,6 +1966,7 @@ components:
totalPackages: { type: integer }
receivedPackages: { type: integer }
pendingPackages: { type: integer }
hasDifference: { type: boolean, description: 是否存在短少或普通FBA超收差异 }
inbounds: { type: array, items: { $ref: '#/components/schemas/ReceivingInbound' } }
ReceivingPhoto:
type: object
@@ -1942,12 +1980,16 @@ components:
allOf:
- { $ref: '#/components/schemas/ReceivingInbound' }
- type: object
required: [cartonMark]
required: [cartonMark, operationMode, groupKey, inboundCodes]
properties:
cartonMark: { type: string }
operationMode: { type: string, enum: [DROPSHIP_PALLET, FBA_GROUP], nullable: true }
groupKey: { type: string, nullable: true }
inboundCodes: { type: array, items: { type: string } }
sourcePalletNo: { type: string, nullable: true }
ReceivingPalletResolved:
type: object
required: [palletCode, warehouseId, status, inboundCode, destinationType, compatible]
required: [palletCode, warehouseId, status, inboundCode, destinationType, compatible, version, currentReceivedPackages]
properties:
palletCode: { type: string }
warehouseId: { type: integer, format: int64 }
@@ -1956,6 +1998,8 @@ components:
inboundCode: { type: string }
destinationType: { type: string, enum: [FBA, OVERSEAS] }
compatible: { type: boolean }
version: { type: integer, minimum: 0 }
currentReceivedPackages: { type: integer, minimum: 0 }
ReceivingReceipt:
type: object
required: [result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages, receivedPackages, pendingPackages, sessionStatus, assigned, mergeAllowed, urgent]
@@ -1975,6 +2019,8 @@ components:
mergeHint: { type: string, nullable: true }
orderType: { type: string, nullable: true }
wholePalletInbound: { type: boolean, description: 一件代发必须按完整来源托盘扫描收货 }
groupKey: { type: string, nullable: true }
inboundCodes: { type: array, items: { type: string } }
ReceivingCandidateListApiResponse:
allOf:
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
@@ -2280,6 +2326,10 @@ components:
partialCompletionReason: { type: string, nullable: true }
createdAt: { type: string, format: date-time }
items: { type: array, items: { $ref: '#/components/schemas/DispatchItem' } }
bolLines:
type: array
description: 仅详情接口返回的BOL打印聚合行;分页接口返回空数组,避免列表查询放大
items: { $ref: '#/components/schemas/DispatchBolLine' }
DispatchItem:
type: object
properties:
@@ -2298,6 +2348,17 @@ components:
packageCount: { type: integer }
volumeCbm: { type: number }
sourceStage: { type: string, enum: [PLAN, RECEIVED] }
DispatchBolLine:
type: object
properties:
containerNo: { type: string, nullable: true }
palletNo: { type: string, nullable: true }
fbaWarehouseCode: { type: string, nullable: true }
destinationWarehouseName: { type: string, nullable: true }
warehouseNo: { type: string, nullable: true, description: 来源入仓码/仓库单号 }
fbaShipmentId: { type: string, nullable: true, description: 来源冻结货件编号;多值以逗号分隔 }
poReferenceNo: { type: string, nullable: true, description: 来源冻结FBA跟踪/参考号;多值以逗号分隔 }
packageCount: { type: integer, minimum: 0 }
RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, CUSTOMER_CONFIRM_PENDING, READY_FOR_OUTBOUND, COMPLETED, REJECTED, CANCELLED] }
RelabelMerchantPageResponse:
allOf:
@@ -2774,12 +2835,15 @@ components:
version: { type: integer }
InboundPlanDetail:
type: object
required: [id, planNo, customerId, overseasWarehouseCode, overseasWarehouseName, status, lineCount, palletCount, totalVolumeCbm, expectedPackageCount, pickedPackageCount, lines, pallets, allocations, version]
required: [id, planNo, customerId, overseasWarehouseCode, overseasWarehouseName, status, lineCount, palletCount, totalVolumeCbm, expectedPackageCount, pickedPackageCount, lines, pallets, allocations, version, createdAt, updatedAt]
properties:
id: { type: string, pattern: '^[0-9]+$' }
planNo: { type: string }
customerId: { type: string }
sourceOrderId: { type: string, pattern: '^[0-9]+$', nullable: true }
sourceOrderNo: { type: string, nullable: true, description: 关联尾程订单业务单号;手工入库计划为空 }
customerName: { type: string, nullable: true, description: 货主名称;手工入库计划为空 }
orderType: { type: string, nullable: true, description: 关联尾程订单类型;手工入库计划为空 }
containerNo: { type: string, nullable: true }
overseasWarehouseCode: { type: string, description: 接柜仓编码,取自尾程订单预分配接收仓事实 }
overseasWarehouseName: { type: string, description: 接柜仓名称,取自尾程订单预分配接收仓事实 }
@@ -2796,13 +2860,15 @@ components:
urgent: { type: boolean }
creationMode: { type: string, enum: [AUTO, MANUAL] }
remark: { type: string, nullable: true }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
lines: { type: array, items: { $ref: '#/components/schemas/InboundPlanLine' } }
pallets: { type: array, items: { $ref: '#/components/schemas/InboundPlanPallet' } }
allocations: { type: array, items: { $ref: '#/components/schemas/InboundPlanAllocation' } }
version: { type: integer }
ContainerInboundPlanPreview:
type: object
required: [sourceContainerBookingId, snapshotVersion, snapshotHash, totalOrderCount, readyOrderCount, blockedOrderCount, pendingOrderCount, pendingPalletCount, existingPlannedPalletCount, totalSuggestedPalletCount, totalPlannedPalletCount, ready, groups]
required: [sourceContainerBookingId, snapshotVersion, snapshotHash, totalOrderCount, readyOrderCount, blockedOrderCount, pendingOrderCount, pendingPalletCount, sourcePalletCount, supplementPalletCount, existingPlannedPalletCount, totalSuggestedPalletCount, totalPlannedPalletCount, ready, groups]
properties:
sourceContainerBookingId: { type: string }
containerNo: { type: string, nullable: true }
@@ -2815,7 +2881,9 @@ components:
readyOrderCount: { type: integer }
blockedOrderCount: { type: integer }
pendingOrderCount: { type: integer, description: 本次确认将新建计划的 READY 订单数 }
pendingPalletCount: { type: integer, description: 本次确认将向资源服务批量预留的新托盘数 }
pendingPalletCount: { type: integer, description: 本次确认最终向资源服务批量预留的托盘总数,包含头程已有编码及海外补充编码 }
sourcePalletCount: { type: integer, description: 本次处理订单随头程装柜完成事件冻结并传入的托盘码数 }
supplementPalletCount: { type: integer, description: 最终托数超过头程已有托盘码数时,在海外仓连续补充的托盘码数 }
existingPlannedPalletCount: { type: integer, description: 已有有效计划的托盘总数,不计入本次新增 }
totalSuggestedPalletCount: { type: integer }
totalPlannedPalletCount: { type: integer }
@@ -2823,7 +2891,7 @@ components:
groups: { type: array, items: { $ref: '#/components/schemas/ContainerInboundPlanPreviewGroup' } }
ContainerInboundPlanPreviewGroup:
type: object
required: [destinationType, orderCount, pendingOrderCount, pendingPalletCount, existingPlannedPalletCount, suggestedPalletCount, plannedPalletCount, orders]
required: [destinationType, orderCount, pendingOrderCount, pendingPalletCount, sourcePalletCount, supplementPalletCount, existingPlannedPalletCount, suggestedPalletCount, plannedPalletCount, orders]
properties:
destinationType: { type: string, enum: [FBA, ONE_CLICK_DROPSHIP, PRIVATE_ADDRESS] }
destinationWarehouseId: { type: string, nullable: true }
@@ -2832,13 +2900,15 @@ components:
orderCount: { type: integer }
pendingOrderCount: { type: integer }
pendingPalletCount: { type: integer }
sourcePalletCount: { type: integer }
supplementPalletCount: { type: integer }
existingPlannedPalletCount: { type: integer }
suggestedPalletCount: { type: integer }
plannedPalletCount: { type: integer }
orders: { type: array, items: { $ref: '#/components/schemas/ContainerInboundPlanPreviewOrder' } }
ContainerInboundPlanPreviewOrder:
type: object
required: [customerId, orderId, businessOrderNo, orderType, destinationType, packageCount, volumeCbm, orderVersion, readiness, blockers]
required: [customerId, orderId, businessOrderNo, orderType, destinationType, packageCount, volumeCbm, sourcePalletCount, targetPalletCount, supplementPalletCount, orderVersion, readiness, blockers]
properties:
customerId: { type: string }
customerName: { type: string, nullable: true }
@@ -2854,11 +2924,14 @@ components:
packageCount: { type: integer }
volumeCbm: { type: number }
suggestedPalletCount: { type: integer, nullable: true }
sourcePalletCount: { type: integer, description: 头程已经冻结并传入的托盘码数 }
targetPalletCount: { type: integer, description: 本票最终生成的托盘总数,不少于头程已有托盘码数 }
supplementPalletCount: { type: integer, description: 本票需要在海外仓连续补充的托盘码数 }
plannedPalletCount: { type: integer, nullable: true }
existingPlanNo: { type: string, nullable: true }
planStatus: { type: string, nullable: true }
orderVersion: { type: integer, minimum: 0 }
readiness: { type: string, enum: [READY, PLANNED, BLOCKED] }
readiness: { type: string, enum: [READY, RESOURCE_PENDING, PLANNED, BLOCKED] }
blockers: { type: array, items: { type: string } }
InboundPlanLine:
type: object
@@ -2897,6 +2970,9 @@ components:
properties:
lineId: { type: string, pattern: '^[0-9]+$' }
palletId: { type: string, pattern: '^[0-9]+$' }
palletNo: { type: string, description: 分配实际引用的托盘码,跨计划合托时仍返回真实托盘码 }
palletOwnerPlanNo: { type: string, description: 托盘所属入库计划号 }
palletOwnedByCurrentPlan: { type: boolean, description: 托盘是否归属当前详情入库计划 }
allocatedVolumeCbm: { type: number }
plannedPackageCount: { type: integer }
countedPackageCount: { type: integer }
@@ -33,6 +33,7 @@ class DomesticWarehouseDirectoryClientContractTest {
"warehouseType:String", "countryCode:String", "status:String");
assertThat(Arrays.stream(DomesticWarehouseDirectoryClient.Address.class.getRecordComponents())
.map(value -> value.getName() + ":" + value.getType().getSimpleName()))
.containsExactly("stateProvince:String");
.containsExactly("addressType:String", "countryCode:String", "stateProvince:String", "city:String",
"district:String", "postalCode:String", "addressLine1:String", "addressLine2:String");
}
}
@@ -58,7 +58,7 @@ class Phase4BrowserApiBoundaryTest {
assertTrue(Arrays.asList(condition.name()).contains("lastleg.dispatch-query-api.enabled"));
assertFalse(condition.matchIfMissing());
assertTrue(Arrays.stream(DispatchQueryController.class.getDeclaredMethods())
.filter(method -> method.isAnnotationPresent(GetMapping.class)).count() == 2);
.filter(method -> method.isAnnotationPresent(GetMapping.class)).count() == 3);
assertFalse(Arrays.stream(DispatchQueryController.class.getDeclaredMethods())
.flatMap(method -> Arrays.stream(method.getAnnotations()))
.anyMatch(annotation -> Set.of("PostMapping", "PutMapping", "DeleteMapping")
@@ -72,6 +72,14 @@ class Phase4BrowserApiBoundaryTest {
assertTrue(yaml.contains("enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true}"));
}
@Test
void overseasLastlegReachesDomesticPrintThroughSharedInfrastructureNetwork() throws Exception {
Path composePath = Path.of("deploy/docker-compose.overseas.yml");
if (!Files.exists(composePath)) composePath = Path.of("..").resolve(composePath);
String compose = Files.readString(composePath);
assertTrue(compose.contains("PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207}"));
}
@Test
void smartDispatchOnlyExposesCandidatePreArrangeAndDraftLifecycleEndpoints() {
ConditionalOnProperty condition = SmartDispatchController.class.getAnnotation(ConditionalOnProperty.class);
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.service.DispatchService;
import com.baiyue.lastleg.dispatch.service.DispatchBolPrintService;
import com.baiyue.lastleg.security.OverseasPermissions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -25,12 +26,14 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DispatchQueryControllerTest {
@Mock DispatchService service;
@Mock DispatchBolPrintService printService;
private DispatchQueryController controller;
@BeforeEach
void setUp() {
controller = new DispatchQueryController();
ReflectionTestUtils.setField(controller, "service", service);
ReflectionTestUtils.setField(controller, "printService", printService);
}
@Test
@@ -57,9 +60,13 @@ class DispatchQueryControllerTest {
PreAuthorize detail = DispatchQueryController.class
.getMethod("detail", AuthenticatedUser.class, String.class)
.getAnnotation(PreAuthorize.class);
PreAuthorize print = DispatchQueryController.class
.getMethod("printBol", AuthenticatedUser.class, String.class, jakarta.servlet.http.HttpServletRequest.class)
.getAnnotation(PreAuthorize.class);
assertThat(page.value()).isEqualTo("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')");
assertThat(detail.value()).isEqualTo("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')");
assertThat(print.value()).isEqualTo("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')");
}
private static AuthenticatedUser platform() {
@@ -33,6 +33,7 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -227,7 +228,7 @@ class DispatchPdaWorkflowServiceTest {
}
@Test
void successfulDepartureRecordsActualDepartureTime() {
void successfulDeparturePersistsBusinessDepartureTime() {
DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART");
order.setVehiclePlate("CA-100");
DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED");
@@ -244,7 +245,6 @@ class DispatchPdaWorkflowServiceTest {
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
when(picks.totalItem("100", 11L, 21L)).thenReturn(6L);
when(picks.listTask("100", 11L)).thenReturn(List.of(pick));
DispatchPdaResponses.Departed result = service.depart("WH-1", "DSP-1", "7",
new DispatchPdaRequests.Depart("depart-success", null));
@@ -252,6 +252,10 @@ class DispatchPdaWorkflowServiceTest {
assertEquals("DEPARTED", order.getStatus());
assertTrue(order.getDepartAt() != null);
assertEquals("OUTBOUNDED", outbound.getStatus());
when(palletLocks.selectList(any())).thenReturn(List.of());
assertNotNull(order.getDepartAt());
assertEquals(order.getDepartAt(), order.getUpdatedAt());
verify(orders).updateById(order);
}
@Test
@@ -0,0 +1,78 @@
package com.baiyue.lastleg.dispatch.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
import com.baiyue.lastleg.client.PrintPdfClient;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
@ExtendWith(MockitoExtension.class)
class DispatchBolPrintServiceTest {
@Mock private DispatchService dispatches;
@Mock private PrintPdfClient print;
@Mock private DomesticWarehouseDirectoryClient warehouses;
@InjectMocks private DispatchBolPrintService service;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "internalToken", "internal-test-token");
}
@Test
void usesDenseFirstAndContinuationPageCapacities() {
assertThat(DispatchBolPrintService.pageCount(1)).isEqualTo(1);
assertThat(DispatchBolPrintService.pageCount(18)).isEqualTo(1);
assertThat(DispatchBolPrintService.pageCount(19)).isEqualTo(2);
assertThat(DispatchBolPrintService.pageCount(44)).isEqualTo(2);
assertThat(DispatchBolPrintService.pageCount(45)).isEqualTo(3);
assertThat(DispatchBolPrintService.pageCount(70)).isEqualTo(3);
assertThat(DispatchBolPrintService.pageCount(71)).isEqualTo(4);
}
@Test
void resolvesTheOperatingWarehouseAddressIntoTheBolRequest() {
String warehouseId = "193000000000200003";
DispatchResponse detail = new DispatchResponse(1L, "DS-WH-20260911-624449", "DEV-23653906",
null, null, "五月开发验收车队", null, null, warehouseId, "洛杉矶仓", null,
null, null, null, LocalDateTime.of(2026, 9, 12, 2, 40, 20), "PENDING_DEPARTURE",
1, 1, 1, 0, 0, 1, null, LocalDateTime.of(2026, 9, 11, 10, 0), List.of(),
List.of(new DispatchResponse.BolLine("TSTU3539519", "DP-260911-0027", "ABE2", "ABE2",
"NYEXX2609110214", null, null, 1)));
var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga",
null, "91730", "10767 7th Street", null);
var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L,
"US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE");
when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail);
when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token"))
.thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse, List.of(address))));
when(print.generateDispatchBol(eq("internal-test-token"), eq("request-1"), any()))
.thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF)
.header("X-Print-Page-Count", "1").body("%PDF-".getBytes(StandardCharsets.US_ASCII)));
service.generate(detail.dispatchNo(), "request-1");
ArgumentCaptor<PrintPdfClient.Request> request = ArgumentCaptor.forClass(PrintPdfClient.Request.class);
verify(print).generateDispatchBol(eq("internal-test-token"), eq("request-1"), request.capture());
assertThat(request.getValue().documents()).singleElement().satisfies(document ->
assertThat(document.shipFromAddress()).isEqualTo(
"10767 7th Street, Rancho Cucamonga, California 91730, US"));
}
}
@@ -10,6 +10,7 @@ import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchDraftContainerMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
@@ -50,6 +51,7 @@ import static org.mockito.Mockito.when;
class DispatchServiceTest {
@Mock DispatchOrderMapper orders;
@Mock DispatchItemMapper items;
@Mock DispatchBolMapper bolLines;
@Mock DispatchPalletLockMapper locks;
@Mock DispatchCandidateMapper candidates;
@Mock DispatchDraftMapper drafts;
@@ -64,6 +66,7 @@ class DispatchServiceTest {
service = new DispatchService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "bolLines", bolLines);
ReflectionTestUtils.setField(service, "locks", locks);
ReflectionTestUtils.setField(service, "candidates", candidates);
ReflectionTestUtils.setField(service, "drafts", drafts);
@@ -73,6 +76,7 @@ class DispatchServiceTest {
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator());
lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of());
lenient().when(bolLines.findByDispatchOrder(any(), any())).thenReturn(List.of());
lenient().when(tailOrders.selectList(any())).thenReturn(List.of());
}
@@ -349,6 +353,7 @@ class DispatchServiceTest {
service.detail("200", "PC1");
verify(items).findByDispatchOrder("200", 1L);
verify(bolLines).findByDispatchOrder("200", 1L);
}
@Test
@@ -366,6 +371,7 @@ class DispatchServiceTest {
assertEquals(2, result.total());
verify(items).findByDispatchOrder("100", 1L);
verify(items).findByDispatchOrder("200", 2L);
verify(bolLines, never()).findByDispatchOrder(any(), any());
}
@Test
@@ -374,9 +380,15 @@ class DispatchServiceTest {
dispatch.setId(1L); dispatch.setCustomerId("200"); dispatch.setDispatchNo("PC1");
when(orders.findGlobalByDispatchNo("PC1")).thenReturn(dispatch);
service.platformDetail("PC1");
var expected = new DispatchResponse.BolLine("D2144", "D2144-112-1", "LBE1", "Amazon LBE1",
"NPHK2607030206", "FBA19HFHQCHB", "7UH3F1DW", 138);
when(bolLines.findByDispatchOrder("200", 1L)).thenReturn(List.of(expected));
var result = service.platformDetail("PC1");
verify(items).findByDispatchOrder("200", 1L);
verify(bolLines).findByDispatchOrder("200", 1L);
assertEquals(List.of(expected), result.bolLines());
}
@Test
@@ -69,6 +69,6 @@ class DropshipOrderControllerContractTest {
return new DropshipRequests.Receive("100", 10L, 20L, "event-1",
List.of(new DropshipRequests.Pallet("PALLET-1",
List.of(new DropshipRequests.SkuPackages("SKU-1", "Product", 1, 2,
List.of("CARTON-1"))))));
List.of("CARTON-1"))), 1, 1, 1, "0".repeat(64))));
}
}
@@ -52,10 +52,10 @@ class DropshipOrderServiceTest {
List.of(
new DropshipRequests.Pallet("SOURCE-P-1", List.of(
new DropshipRequests.SkuPackages("SKU-A", "Product A", 2, 20,
List.of("MARK-1", "MARK-2")))),
List.of("MARK-1", "MARK-2"))), 1, 2, 1, "0".repeat(64)),
new DropshipRequests.Pallet("SOURCE-P-2", List.of(
new DropshipRequests.SkuPackages("SKU-A", "Product A", 1, 10,
List.of("MARK-3"))))));
List.of("MARK-3"))), 1, 1, 1, "1".repeat(64))));
assertThat(service.receive(request)).isEqualTo(3);
verifyNoInteractions(warehouse);
@@ -21,6 +21,7 @@ class InboundPlanSchemaContractTest {
assertTrue(schema.contains("required_package_count"));
assertTrue(schema.contains("planned_package_count"));
assertTrue(schema.contains("warehouse_resource_lock_id"));
assertTrue(schema.contains("ll_inbound_plan_pallet' and column_name='version'"));
assertTrue(schema.contains("create table if not exists ll_inbound_pda_task"));
assertTrue(schema.contains("create table if not exists ll_inbound_pda_operation"));
assertTrue(schema.contains("create table if not exists ll_inbound_pallet_merge"));
@@ -38,8 +38,9 @@ class ReceivingContainerContractTest {
.map(RecordComponent::getName).collect(Collectors.toSet());
assertTrue(java.util.Collections.disjoint(fields, FORBIDDEN_CALLER_SCOPE), type.getSimpleName());
}
for (Class<?> type : Set.of(ReceivingContainerRequests.Action.class, ReceivingContainerRequests.Sign.class,
ReceivingContainerRequests.BulkReceive.class, ReceivingContainerRequests.CartonReceive.class)) {
for (Class<?> type : Set.of(ReceivingContainerRequests.Action.class, ReceivingContainerRequests.Complete.class,
ReceivingContainerRequests.Sign.class, ReceivingContainerRequests.BulkReceive.class,
ReceivingContainerRequests.CartonReceive.class, ReceivingContainerRequests.WholePalletReceive.class)) {
RecordComponent key = Arrays.stream(type.getRecordComponents())
.filter(component -> "idempotencyKey".equals(component.getName())).findFirst().orElseThrow();
Size size = key.getAccessor().getAnnotation(Size.class);
@@ -67,7 +68,7 @@ class ReceivingContainerContractTest {
@Test
void inventoryCreatingEntryTransactionsAvoidMissingDimensionGapLockDeadlocks() throws NoSuchMethodException {
Transactional pda = ReceivingContainerService.class
.getMethod("complete", String.class, ReceivingContainerRequests.Action.class)
.getMethod("complete", String.class, ReceivingContainerRequests.Complete.class)
.getAnnotation(Transactional.class);
Transactional bridge = LoadingCompletedConsumer.class.getMethod("consume", EventEnvelope.class)
.getAnnotation(Transactional.class);
@@ -103,6 +104,10 @@ class ReceivingContainerContractTest {
assertTrue(unified.contains("alter table ll_container_shipping_gate drop column customer_id"));
assertTrue(schema.contains("create table if not exists ll_receiving_sku_receipt"));
assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)"));
assertTrue(schema.contains("create table if not exists ll_receiving_fba_pallet_count"));
assertTrue(schema.contains("unique key uk_ll_receiving_fba_count(session_id,group_key,pallet_code)"));
assertTrue(tableBlock(schema, "ll_inbound_plan_pallet").contains("key idx_ll_inbound_pallet_no (pallet_no)"));
assertFalse(tableBlock(schema, "ll_inbound_plan_pallet").contains("unique key uk_ll_inbound_pallet_no"));
String order = tableBlock(schema, "ll_order");
assertTrue(order.contains("overseas_warehouse_id varchar(128) null"));
assertTrue(order.contains("overseas_warehouse_code varchar(128) null"));
@@ -113,6 +118,7 @@ class ReceivingContainerContractTest {
assertTrue(receivingSession.contains("overseas_warehouse_name varchar(255) not null"));
assertFalse(receivingSession.contains("photo_file_ids json"));
assertTrue(unified.contains("create table if not exists ll_receiving_sku_receipt"));
assertTrue(unified.contains("create table if not exists ll_receiving_fba_pallet_count"));
assertTrue(unified.contains("column_name='sign_photos'"));
assertTrue(schema.contains("wait_sign、signed、receiving、completed"));
assertTrue(unified.contains("customer_id is null or l.customer_id=''"));
@@ -120,7 +126,7 @@ class ReceivingContainerContractTest {
assertTrue(unified.contains("customer_id is null or a.customer_id=''"));
assertTrue(unified.contains("signal sqlstate '45000'"));
assertTrue(unified.contains("assert_receiving_global_locks_migratable"));
assertTrue(unified.contains("where status='completed' and (active_operator_key is not null or active_container_key is not null)"));
assertTrue(unified.contains("where status in ('completed','completed_with_difference') and (active_operator_key is not null or active_container_key is not null)"));
assertTrue(unified.contains("concat('operator:',operator_key)"));
assertTrue(unified.contains("concat('container:',upper(trim(container_no)))"));
assertTrue(unified.contains("assert_receiving_operation_global_key_migratable"));
@@ -30,6 +30,7 @@ import org.springframework.mock.web.MockMultipartFile;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -596,6 +597,24 @@ class InboundPdaWorkflowServiceTest {
any(), any(), anyLong());
}
@Test
void completionLoadsSharedFbaPalletOwnedByAnotherCustomerPlan() {
activeTask();
target.setPickedPackageCount(5);
InboundPlanPallet sharedPallet = pallet(40L, "WH-A", 5, "1.0000", 400L);
sharedPallet.setPlanId(99L);
target.setPalletId(sharedPallet.getId());
when(repository.allocations(plan.getId())).thenReturn(List.of(target));
when(repository.lines(plan.getId())).thenReturn(List.of(targetLine));
when(repository.pallets(plan.getId())).thenReturn(List.of());
when(repository.palletsByIds(Set.of(sharedPallet.getId()))).thenReturn(List.of(sharedPallet));
service.complete("100", "LIP1");
verify(stockReceipts).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(targetLine)),
eq(List.of(target)), eq(List.of(sharedPallet)), any());
}
@Test
void completedTaskReplayDoesNotRunStockInAgain() {
task.setStatus("COMPLETED");
@@ -16,11 +16,17 @@ import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole;
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.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.dropship.mapper.DropshipCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
@@ -58,8 +64,11 @@ class ReceivingContainerServiceBehaviorTest {
@Mock LastlegOrderDetailMapper orderDetails;
@Mock ReceivingContainerSessionMapper sessions;
@Mock ReceivingContainerOperationMapper operations;
@Mock ReceivingFbaPalletCountMapper fbaPalletCounts;
@Mock ReceivingSkuReceiptMapper skuReceipts;
@Mock ContainerShippingGateMapper shippingGates;
@Mock DropshipCartonMapper dropshipCartons;
@Mock DropshipOrderProfileMapper dropshipProfiles;
@Mock WarehouseResourceGateway warehouse;
@Mock UnloadPhotoFileGateway unloadPhotos;
@Mock InboundPdaWorkflowService workflow;
@@ -77,8 +86,11 @@ class ReceivingContainerServiceBehaviorTest {
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
ReflectionTestUtils.setField(service, "sessions", sessions);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "fbaPalletCounts", fbaPalletCounts);
ReflectionTestUtils.setField(service, "skuReceipts", skuReceipts);
ReflectionTestUtils.setField(service, "shippingGates", shippingGates);
ReflectionTestUtils.setField(service, "dropshipCartons", dropshipCartons);
ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles);
ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "unloadPhotos", unloadPhotos);
ReflectionTestUtils.setField(service, "workflow", workflow);
@@ -414,11 +426,10 @@ class ReceivingContainerServiceBehaviorTest {
org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow);
stubCurrent("FBA", "CONT-A");
stubSku();
when(warehouse.resolveAvailablePallet("PAL-1")).thenReturn(resource(101L, "FBA-1"));
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "pallet-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode().value()).isEqualTo(404));
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
}
@@ -426,7 +437,7 @@ class ReceivingContainerServiceBehaviorTest {
void overReceiptAndDuplicateCartonFailBeforeRemoteReservation() {
stubCurrent("FBA", "CONT-A");
stubSku();
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
stubReservedFbaPallet();
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt"))
.when(workflow).validateBulkResolved(any(), any(), any(), anyInt());
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
@@ -442,6 +453,30 @@ class ReceivingContainerServiceBehaviorTest {
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
}
@Test
void fbaOverReceiptAllocatesOnlyPlannedBalanceAndRecordsPhysicalDifference() {
stubCurrent("FBA", "CONT-A");
stubSku();
stubReservedFbaPallet();
when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()))
.thenAnswer(invocation -> invocation.getArgument(4));
when(fbaPalletCounts.insert(any(ReceivingFbaPalletCount.class))).thenReturn(1);
when(fbaPalletCounts.sumActual(8L, "FBA:CONT-A:FBA-1")).thenReturn(3);
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
var result = service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 3, "over-mark-key"));
assertThat(result.result()).isEqualTo("DIFFERENCE_RECORDED");
assertThat(result.acceptedPackages()).isEqualTo(3);
verify(workflow).receiveBulkResolved(any(), any(), any(), any(),
org.mockito.ArgumentMatchers.eq(2), any(), any());
ArgumentCaptor<ReceivingFbaPalletCount> count = ArgumentCaptor.forClass(ReceivingFbaPalletCount.class);
verify(fbaPalletCounts).insert(count.capture());
assertThat(count.getValue().getActualPackages()).isEqualTo(3);
}
@Test
void overseasPalletUsesActualReceivingWarehouseInsteadOfBusinessDestinationId() {
stubCurrent("OVERSEAS", "CONT-A");
@@ -464,7 +499,7 @@ class ReceivingContainerServiceBehaviorTest {
InboundPlanPalletAllocation partial = new InboundPlanPalletAllocation();
partial.setLineId(10L); partial.setPickedPackageCount(1);
when(allocations.selectList(any())).thenReturn(List.of(partial));
assertThatThrownBy(() -> service.complete("7", new ReceivingContainerRequests.Action("complete-key")))
assertThatThrownBy(() -> service.complete("7", new ReceivingContainerRequests.Complete(false, "complete-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(workflow, never()).completeContainer(any());
}
@@ -480,7 +515,7 @@ class ReceivingContainerServiceBehaviorTest {
when(allocations.selectList(any())).thenReturn(List.of(received));
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
var completed = service.complete("7", new ReceivingContainerRequests.Action("dropship-complete"));
var completed = service.complete("7", new ReceivingContainerRequests.Complete(false, "dropship-complete"));
assertThat(completed.status()).isEqualTo("COMPLETED");
verifyNoInteractions(skuReceipts);
@@ -491,11 +526,11 @@ class ReceivingContainerServiceBehaviorTest {
void completedRequestReplaysWithoutAnActiveCustomerSession() {
ReceivingContainerOperation operation = new ReceivingContainerOperation();
operation.setOperatorKey("7"); operation.setAction("COMPLETE"); operation.setIdempotencyKey("complete-replay");
operation.setRequestHash(hash(new ReceivingContainerRequests.Action("complete-replay")));
operation.setRequestHash(hash(new ReceivingContainerRequests.Complete(false, "complete-replay")));
operation.setResultJson("{\"containerNo\":\"CONT-A\",\"status\":\"COMPLETED\",\"operatorName\":\"receiver\",\"version\":1,\"photos\":[],\"totalPackages\":2,\"receivedPackages\":2,\"pendingPackages\":0,\"inbounds\":[]}");
when(operations.find("7", "complete-replay")).thenReturn(operation);
assertThat(service.complete("7", new ReceivingContainerRequests.Action("complete-replay")).status())
assertThat(service.complete("7", new ReceivingContainerRequests.Complete(false, "complete-replay")).status())
.isEqualTo("COMPLETED");
verifyNoInteractions(sessions);
}
@@ -504,8 +539,7 @@ class ReceivingContainerServiceBehaviorTest {
void localFailureAfterRemoteReservationTriggersReleaseCompensation() {
stubCurrent("FBA", "CONT-A");
stubSku();
when(warehouse.resolveAvailablePallet("PAL-1")).thenReturn(resource(100L, "FBA-1"));
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
stubReservedFbaPallet();
when(workflow.assignResolvedPallet(any(), any(), any(), any())).thenReturn(55L);
ResponseStatusException failure = new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "race");
doThrow(failure).when(workflow).receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any());
@@ -675,9 +709,10 @@ class ReceivingContainerServiceBehaviorTest {
}
@Test
void bulkRejectsAggregateSkuOverReceiptBeforeWarehouseLookup() {
void bulkRejectsInconsistentFrozenSkuProgressBeforeAnyReceiptWrite() {
stubCurrent("FBA", "CONT-A");
stubSku();
stubReservedFbaPallet();
com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt received =
new com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt();
received.setReceivedPackages(2);
@@ -686,7 +721,7 @@ class ReceivingContainerServiceBehaviorTest {
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "sku-over")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse);
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
}
@Test
@@ -707,7 +742,7 @@ class ReceivingContainerServiceBehaviorTest {
}
@Test
void cartonResolutionReturnsAssignmentOptionalMergeAndUrgencyFromInboundPlan() {
void fbaGroupResolutionKeepsUrgencyWithoutPerPlanMergeChoices() {
stubCurrent("FBA", "CONT-A");
InboundPlan flagged = plan("CONT-A");
flagged.setAssignedFlag(true);
@@ -719,9 +754,9 @@ class ReceivingContainerServiceBehaviorTest {
assertThat(resolved.inboundCode()).isEqualTo("IN-1");
assertThat(resolved.assigned()).isTrue();
assertThat(resolved.mergeAllowed()).isTrue();
assertThat(resolved.mergeAllowed()).isFalse();
assertThat(resolved.urgent()).isTrue();
assertThat(resolved.mergeHint()).contains("可选择", "同目的仓", "新托盘码", "不强制");
assertThat(resolved.mergeHint()).isNull();
}
@Test
@@ -788,8 +823,7 @@ class ReceivingContainerServiceBehaviorTest {
when(orders.selectById(2L)).thenReturn(secondOrder);
when(orderDetails.selectList(any())).thenReturn(List.of(detail("SKU-A", 1), detail("SKU-B", 1)),
List.of(detail("SKU-C", 2)));
when(warehouse.resolveAvailablePallet("PAL-1")).thenReturn(resource(100L, "FBA-1"));
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
stubReservedFbaPallet();
when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()))
.thenAnswer(invocation -> invocation.getArgument(4));
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
@@ -813,22 +847,27 @@ class ReceivingContainerServiceBehaviorTest {
}
@Test
void overseasDropshipResolvesOnlyFrozenCartonsAndNeverCreatesLabelsWhileResolving() {
void dropshipCartonResolvesItsCompleteDpInsteadOfAPlanOrCustomer() {
stubCurrent("OVERSEAS", "CONT-A");
LastlegOrder dropship = order("OVERSEAS"); dropship.setOrderType("ONE_CLICK_DROPSHIP");
when(orders.selectById(1L)).thenReturn(dropship);
InboundPlanCarton frozen = carton(1L, "IN-1#2-1");
frozen.setCustomerId("100"); frozen.setSourceInboundDetailId("d-1"); frozen.setSkuCode("SKU-1");
when(cartons.selectOne(any())).thenReturn(frozen);
InboundPlanPallet pallet = planPallet(); pallet.setSourcePalletNo("DP-260909-0001");
pallet.setWarehousePalletId(30L);
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation();
allocation.setId(30L); allocation.setPlanId(2L); allocation.setLineId(10L);
allocation.setPalletId(20L); allocation.setPlannedPackageCount(2); allocation.setPickedPackageCount(0);
when(dropshipCartons.sourcePalletsInContainer("CONT-A", "IN-1#2-1"))
.thenReturn(List.of("DP-260909-0001"));
when(pallets.selectList(any())).thenReturn(List.of(pallet));
when(allocations.selectList(any())).thenReturn(List.of(allocation));
stubCompleteManifest("DP-260909-0001", 2);
var resolved = service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("IN-1#2-1"));
assertThat(resolved.inboundCode()).isEqualTo("IN-1");
assertThat(resolved.destinationType()).isEqualTo("OVERSEAS");
assertThat(resolved.operationMode()).isEqualTo("DROPSHIP_PALLET");
assertThat(resolved.sourcePalletNo()).isEqualTo("DP-260909-0001");
assertThat(resolved.totalPackages()).isEqualTo(2);
verify(cartons, never()).insert(any(InboundPlanCarton.class));
verifyNoInteractions(warehouse);
when(cartons.selectOne(any())).thenReturn(null);
assertThatThrownBy(() -> service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("IN-1#2-9")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("404");
verify(cartons, never()).insert(any(InboundPlanCarton.class));
}
@Test
@@ -837,7 +876,8 @@ class ReceivingContainerServiceBehaviorTest {
LastlegOrder dropship = order("OVERSEAS"); dropship.setOrderType("ONE_CLICK_DROPSHIP");
when(orders.selectById(1L)).thenReturn(dropship);
InboundPlanPallet pallet = planPallet();
pallet.setPalletNo("PAL-PHYSICAL-1"); pallet.setSourcePalletNo("DP-260909-0001");
pallet.setPalletNo("DP-260909-0001"); pallet.setSourcePalletNo("DP-260909-0001");
pallet.setWarehousePalletId(30L);
InboundPlanPalletAllocation pending = new InboundPlanPalletAllocation();
pending.setId(30L); pending.setCustomerId("100"); pending.setPlanId(2L);
pending.setLineId(10L); pending.setPalletId(20L); pending.setPlannedPackageCount(2);
@@ -847,6 +887,7 @@ class ReceivingContainerServiceBehaviorTest {
received.setPickedPackageCount(2);
when(pallets.selectList(any())).thenReturn(List.of(pallet));
when(allocations.selectList(any())).thenReturn(List.of(pending), List.of(received));
stubCompleteManifest("DP-260909-0001", 2);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
@@ -856,7 +897,7 @@ class ReceivingContainerServiceBehaviorTest {
assertThat(result.acceptedPackages()).isEqualTo(2);
assertThat(result.receivedPackages()).isEqualTo(2);
assertThat(result.pendingPackages()).isZero();
assertThat(result.palletCode()).isEqualTo("PAL-PHYSICAL-1");
assertThat(result.palletCode()).isEqualTo("DP-260909-0001");
verify(workflow).pickWholePallet(org.mockito.ArgumentMatchers.eq("100"),
org.mockito.ArgumentMatchers.eq("PLAN-1"), org.mockito.ArgumentMatchers.eq(30L),
any(com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests.WholePalletOperation.class));
@@ -940,6 +981,28 @@ class ReceivingContainerServiceBehaviorTest {
return value;
}
private InboundPlanPallet stubReservedFbaPallet() {
InboundPlanPallet value = planPallet();
value.setPalletNo("PAL-1"); value.setWarehousePalletId(30L); value.setWarehouseLocationId(40L);
value.setWarehouseResourceLockId(50L); value.setAssignmentStatus("RESERVED"); value.setVersion(0);
when(pallets.selectList(any())).thenReturn(List.of(value));
when(warehouse.resolveReservedPallet("PAL-1", "100", 100L, 30L, 40L, 50L))
.thenReturn(new WarehouseResourceGateway.WarehousePalletReference(
30L, "PAL-1", 100L, 40L, "OCCUPIED", "FBA-1", 0));
return value;
}
private void stubCompleteManifest(String sourcePalletNo, int packages) {
DropshipOrderProfile profile = new DropshipOrderProfile();
profile.setId(60L); profile.setCustomerId("100"); profile.setSourcePalletNo(sourcePalletNo);
profile.setManifestVersion(1); profile.setManifestPackageCount(packages);
profile.setManifestFragmentCount(1); profile.setManifestHash("manifest-hash");
when(dropshipProfiles.listContainerPallet("CONT-A", sourcePalletNo)).thenReturn(List.of(profile));
List<DropshipCarton> values = new java.util.ArrayList<>();
for (int index = 0; index < packages; index++) values.add(new DropshipCarton());
when(dropshipCartons.listProfile("100", 60L)).thenReturn(values);
}
private static WarehouseResourceGateway.WarehousePalletReference resource(long warehouseId, String fbaCode) {
return new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-1", warehouseId, 40L,
"AVAILABLE", fbaCode, 0);
@@ -87,7 +87,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
var ready = new ContainerInboundPlanPreviewResponse.Order(
"200", "Customer 200", "12", "LM-12", "RK-2", "FIRST_LEG", "FBA",
null, "ONT8", "Amazon ONT8", "ONT8", 10, BigDecimal.ONE, 1,
null, null, null, 0, "READY", List.of());
0, 1, 1, null, null, null, 0, "READY", List.of());
when(plans.convertManual(request, "manual-key", "7")).thenReturn(logical);
when(plans.sourceContainerBookingId("100", 11L)).thenReturn(BOOKING_ID);
when(plans.previewContainer(BOOKING_ID)).thenReturn(manualPreview(List.of(resourcePendingOrder(), ready)));
@@ -272,11 +272,11 @@ class ContainerInboundPlanConversionCoordinatorTest {
var fba = previewOrder("100", "11", "RK-FBA", "ABE3", "ABE3");
var overseas = previewOrder("200", "12", "RK-OVERSEAS", "US-LAX-01", null);
var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
"Los Angeles", 7L, SNAPSHOT_HASH, 2, 2, 0, 2, 2, 0, 2, 0, true,
"Los Angeles", 7L, SNAPSHOT_HASH, 2, 2, 0, 2, 2, 2, 0, 0, 2, 0, true,
List.of(new ContainerInboundPlanPreviewResponse.Group("ONE_CLICK_DROPSHIP", null, "ABE3", "Amazon ABE3",
1, 1, 1, 0, 1, 0, List.of(fba)),
1, 1, 1, 1, 0, 0, 1, 0, List.of(fba)),
new ContainerInboundPlanPreviewResponse.Group("ONE_CLICK_DROPSHIP", "31", "US-LAX-01", "Los Angeles",
1, 1, 1, 0, 1, 0, List.of(overseas))));
1, 1, 1, 1, 0, 0, 1, 0, List.of(overseas))));
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class)))
.thenReturn("IP-FBA", "IP-OVERSEAS");
@@ -305,11 +305,11 @@ class ContainerInboundPlanConversionCoordinatorTest {
var overseas = new ContainerInboundPlanPreviewResponse.Order(
"200", "Customer 200", "12", "LM-12", "RK-OVERSEAS", "FIRST_LEG", "FIRST_LEG",
"31", "US-LAX-01", "Los Angeles", null, 10, BigDecimal.ONE, 2,
null, null, null, 0, "READY", List.of());
0, 2, 2, null, null, null, 0, "READY", List.of());
var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
"Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 2, 2, 0, 1, 0, true,
"Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 1, 2, 0, 2, 0, 1, 0, true,
List.of(new ContainerInboundPlanPreviewResponse.Group("FIRST_LEG", "31", "US-LAX-01", "Los Angeles",
1, 1, 2, 0, 2, 0, List.of(overseas))));
1, 1, 2, 0, 2, 0, 2, 0, List.of(overseas))));
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-OVERSEAS");
when(plans.sourcePalletNos("200", 12L, "FIRST_LEG")).thenReturn(List.of());
@@ -337,11 +337,11 @@ class ContainerInboundPlanConversionCoordinatorTest {
var order = new ContainerInboundPlanPreviewResponse.Order(
"100", "Customer 100", "11", "LM-11", "RK-FBA", "FIRST_LEG", "FBA",
null, "ACY2", "Amazon ACY2", "ACY2", 20, new BigDecimal("5.8000"), 3,
null, null, null, 0, "READY", List.of());
2, 3, 1, null, null, null, 0, "READY", List.of());
var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
"Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 1, 3, 0, 3, 0, true,
"Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 1, 3, 2, 1, 0, 3, 0, true,
List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2",
1, 1, 3, 0, 3, 0, List.of(order))));
1, 1, 3, 2, 1, 0, 3, 0, List.of(order))));
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-ACY2");
when(plans.sourcePalletNos("100", 11L, "FIRST_LEG")).thenReturn(List.of(
@@ -369,11 +369,11 @@ class ContainerInboundPlanConversionCoordinatorTest {
var order = new ContainerInboundPlanPreviewResponse.Order(
"100", "Customer 100", "11", "LM-11", "RK-FBA", "FIRST_LEG", "FBA",
null, "ACY2", "Amazon ACY2", "ACY2", 20, new BigDecimal("5.8000"), 3,
3, "IP-MANUAL", "PENDING_ASSIGNMENT", 1, "RESOURCE_PENDING", List.of());
2, 3, 1, 3, "IP-MANUAL", "PENDING_ASSIGNMENT", 1, "RESOURCE_PENDING", List.of());
var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
"Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 1, 3, 3, 3, 3, true,
"Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 1, 3, 2, 1, 3, 3, 3, true,
List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2",
1, 1, 3, 3, 3, 3, List.of(order))));
1, 1, 3, 2, 1, 3, 3, 3, List.of(order))));
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
when(plans.sourcePalletNos("100", 11L, "FIRST_LEG")).thenReturn(List.of(
"MSCU6639870-101-001", "MSCU6639870-101-002"));
@@ -404,7 +404,8 @@ class ContainerInboundPlanConversionCoordinatorTest {
"LM-" + orderId, inboundCode, "ONE_CLICK_DROPSHIP", "ONE_CLICK_DROPSHIP",
fbaWarehouseCode == null ? "31" : null, destinationCode,
fbaWarehouseCode == null ? "Los Angeles" : "Amazon " + destinationCode,
fbaWarehouseCode, 10, BigDecimal.ONE, 1, null, null, null, 0, "READY", List.of());
fbaWarehouseCode, 10, BigDecimal.ONE, 1, 1, 1, 0,
null, null, null, 0, "READY", List.of());
}
private ManualInboundPlanRequest manualRequest() {
@@ -416,7 +417,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
return new ContainerInboundPlanPreviewResponse.Order(
"100", "Customer 100", "11", "LM-11", "RK-1", "FIRST_LEG", "FBA",
null, "ACY2", "Amazon ACY2", "ACY2", 10, BigDecimal.ONE, 1,
1, "IP-MANUAL", "PENDING_ASSIGNMENT", 1, "RESOURCE_PENDING", List.of());
1, 1, 0, 1, "IP-MANUAL", "PENDING_ASSIGNMENT", 1, "RESOURCE_PENDING", List.of());
}
private ContainerInboundPlanPreviewResponse manualPreview(
@@ -425,16 +426,17 @@ class ContainerInboundPlanConversionCoordinatorTest {
|| "READY".equals(order.readiness())).count();
return new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
"Los Angeles", 7L, SNAPSHOT_HASH, orders.size(), orders.size(), 0, pending, pending,
0, orders.size(), 1, true,
pending, 0, 0, orders.size(), 1, true,
List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2",
orders.size(), pending, pending, 0, orders.size(), 1, orders)));
orders.size(), pending, pending, pending, 0, 0, orders.size(), 1, orders)));
}
private InboundPlanResponse plan(String planNo, List<InboundPlanResponse.Pallet> pallets) {
return new InboundPlanResponse(21L, planNo, "100", 11L, "MSCU6639870", "US-LAX-01",
return new InboundPlanResponse(21L, planNo, "100", 11L, "FL-WH-1", "验收客户", "ONE_CLICK_DROPSHIP",
"MSCU6639870", "US-LAX-01",
"Los Angeles", "PENDING_ASSIGNMENT", 1, 1, BigDecimal.ONE, 10, 0,
List.of(), pallets, List.of(), 1, LocalDate.of(2026, 9, 10), "WOOD", false,
false, false, "MANUAL", "自动生成");
false, false, "MANUAL", "自动生成", null, null);
}
private ConvertContainerInboundPlanRequest request() {
@@ -474,6 +476,6 @@ class ContainerInboundPlanConversionCoordinatorTest {
private ContainerInboundPlanPreviewResponse preview(long version, String hash) {
return new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
"Los Angeles", version, hash, 1, 1, 0, 0, 0, 1, 1, 1, true, List.of());
"Los Angeles", version, hash, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, true, List.of());
}
}
@@ -57,6 +57,8 @@ class ContainerInboundPlanPreviewTest {
assertFalse(result.ready());
assertEquals(4, result.pendingOrderCount());
assertEquals(7, result.pendingPalletCount());
assertEquals(7, result.sourcePalletCount());
assertEquals(0, result.supplementPalletCount());
assertEquals(2, result.existingPlannedPalletCount());
assertEquals(List.of("A-01", "B-02", "C-03", "DROPSHIP", ""), result.groups().stream()
.map(group -> group.destinationWarehouseCode() == null ? "" : group.destinationWarehouseCode()).toList());
@@ -74,7 +76,7 @@ class ContainerInboundPlanPreviewTest {
new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", 1L, "LM-1", "FO-1", "测试客户",
"FIRST_LEG", "OVERSEAS", "IN-1", "PENDING_ASSIGNMENT", null, null, 0, 0,
BigDecimal.ZERO, null, null, null, null, null, null, null, 0, false, 1,
null, null, null, null)));
null, null, null, null, null)));
var result = service.previewContainer("BOOKING-NULL");
@@ -118,6 +120,27 @@ class ContainerInboundPlanPreviewTest {
assertEquals("RESOURCE_PENDING", result.groups().get(0).orders().get(0).readiness());
}
@Test
void exposesHeadLegExistingSupplementAndFinalPalletCountsWithoutBlocking() {
when(orderMapper.containerPreviewOrders("BOOKING-SUPPLEMENT")).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", 1L, "LM-1", "FO-1",
"测试客户", "FIRST_LEG", "FBA", "IN-1", "PENDING_ASSIGNMENT", null,
"MSCU6639870", 2, 250, new BigDecimal("5.3000"), "31", "US-LAX-01",
"洛杉矶仓", "ACY2", null, null, "Amazon ACY2", 0, false, 1,
null, null, null, null, 2)));
var result = service.previewContainer("BOOKING-SUPPLEMENT");
var order = result.groups().get(0).orders().get(0);
assertEquals(2, order.sourcePalletCount());
assertEquals(3, order.targetPalletCount());
assertEquals(1, order.supplementPalletCount());
assertEquals(2, result.sourcePalletCount());
assertEquals(1, result.supplementPalletCount());
assertEquals(3, result.pendingPalletCount());
assertEquals("READY", order.readiness());
}
private InboundPlanOrderMapper.ContainerPreviewOrderRow row(
long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName,
int recommendedPallets, String planNo, Integer plannedPallets, int version) {
@@ -137,6 +160,6 @@ class ContainerInboundPlanPreviewTest {
"FBA".equals(destinationType) || destinationCode == null ? null : "WH-" + id,
"FBA".equals(destinationType) ? null : destinationCode, destinationName,
"FBA".equals(destinationType) || destinationCode == null ? 0 : 1, false, version, planNo,
planStatus, plannedPallets, materializedPallets);
planStatus, plannedPallets, materializedPallets, recommendedPallets);
}
}
@@ -84,6 +84,9 @@ class InboundPlanServiceTest {
org.mockito.Mockito.lenient().when(orderMapper.findReceivingWarehouse(any(), any())).thenReturn(
new InboundPlanOrderMapper.ReceivingWarehouseSnapshot(
"193000000000200003", "US-LAX-01", "洛杉矶仓"));
org.mockito.Mockito.lenient().when(orderMapper.findDetailOrder(any(), anyLong())).thenReturn(
new InboundPlanOrderMapper.DetailOrderSnapshot(
"FL-WH-TEST", "测试客户", "ONE_CLICK_DROPSHIP"));
org.mockito.Mockito.lenient().when(warehouseGateway.requireActiveSharedWarehouse(193000000000200003L)).thenReturn(
new WarehouseResourceGateway.WarehouseReference(
193000000000200003L, "US-LAX-01", "洛杉矶仓", "ACTIVE"));
@@ -324,7 +327,49 @@ class InboundPlanServiceTest {
assertEquals("US-NY-01", result.overseasWarehouseCode());
assertEquals("纽约仓", result.overseasWarehouseName());
assertEquals("FL-WH-TEST", result.sourceOrderNo());
assertEquals("测试客户", result.customerName());
assertEquals("ONE_CLICK_DROPSHIP", result.orderType());
verify(orderMapper).findReceivingWarehouse("100", 1L);
verify(orderMapper).findDetailOrder("100", 1L);
}
@Test
void detailResolvesCrossPlanMergedPalletWithoutChangingOwnedPalletList() {
InboundPlan currentPlan = existingPlan("100", 1L, "IP-CURRENT");
InboundPlan ownerPlan = existingPlan("100", 2L, "IP-OWNER");
InboundPlanPallet ownedPallet = new InboundPlanPallet();
ownedPallet.setId(301L);
ownedPallet.setPlanId(currentPlan.getId());
ownedPallet.setPalletNo("DRYU2309389-101-005");
ownedPallet.setSequenceNo(1);
ownedPallet.setActualPackageCount(0);
InboundPlanPallet mergedPallet = new InboundPlanPallet();
mergedPallet.setId(302L);
mergedPallet.setPlanId(ownerPlan.getId());
mergedPallet.setPalletNo("DRYU2309389-101-006");
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation();
allocation.setLineId(201L);
allocation.setPalletId(302L);
allocation.setPlannedPackageCount(150);
allocation.setCountedPackageCount(150);
allocation.setPickedPackageCount(150);
allocation.setVersion(0);
when(planMapper.selectOne(any())).thenReturn(currentPlan);
when(palletMapper.selectList(any())).thenReturn(List.of(ownedPallet));
when(allocationMapper.selectList(any())).thenReturn(List.of(allocation));
when(palletMapper.selectBatchIds(any())).thenReturn(List.of(mergedPallet));
when(planMapper.selectBatchIds(any())).thenReturn(List.of(ownerPlan));
var result = service.detailByPlanNo("100", "IP-CURRENT");
assertEquals(List.of("DRYU2309389-101-005"),
result.pallets().stream().map(pallet -> pallet.palletNo()).toList());
assertEquals("DRYU2309389-101-006", result.allocations().get(0).palletNo());
assertEquals("IP-OWNER", result.allocations().get(0).palletOwnerPlanNo());
assertEquals(false, result.allocations().get(0).palletOwnedByCurrentPlan());
assertEquals(150, result.allocations().get(0).pickedPackageCount());
}
@Test
@@ -304,7 +304,7 @@ class InventoryCommandServiceTest {
position.setPickedPackages(5L);
when(commands.lockPosition("100", 201L)).thenReturn(position);
when(commands.lockBalance("100", 200L)).thenReturn(balance);
when(commands.palletPackages("100", 300L)).thenReturn(0L);
when(commands.palletPackages(300L)).thenReturn(0L);
var result = service.ship("100", "7", new InventoryCommandRequests.Move(
201L, 5, "OUT-1", "OUT-1", null, "ship-1", null));
@@ -177,6 +177,27 @@ class OverseasProcurementServiceTest {
assertEquals("洛杉矶采购仓", order.getPurchaseWarehouseName());
}
@Test
void unchangedHistoricalWarehouseDoesNotBlockUnrelatedEditsAfterDeactivation() {
OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0);
order.setPurchaseWarehouseId("10");
order.setPurchaseWarehouseName("历史采购仓");
when(orders.lockNo("100", "HC1")).thenReturn(order);
when(orders.findNo("100", "HC1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
ObjectMapper mapper = new ObjectMapper();
var request = new OverseasProcurementRequests.Update(0L, null, null, null, null,
null, mapper.getNodeFactory().textNode("10"), null,
null, null, null, null, null, mapper.getNodeFactory().textNode("更新出库信息"));
service.update("100", "HC1", "7", request);
assertEquals("10", order.getPurchaseWarehouseId());
assertEquals("历史采购仓", order.getPurchaseWarehouseName());
verify(warehouseDirectory, never()).required(10L);
verify(orders).updateById(order);
}
@Test
void customerOptionsAreDeduplicatedSnapshotsFromProcurementOrders() {
OverseasProcurementOrder option = new OverseasProcurementOrder();
@@ -90,4 +90,12 @@ class RelabelContractTest {
openApi.indexOf(" RelabelItem:"));
assertFalse(relabelCreate.contains("customerId"));
}
@Test
void missingDeliveryMethodIsValidatedWithoutCallingNullRejectingSetContains() throws Exception {
String service = Files.readString(Path.of("src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java"),
StandardCharsets.UTF_8);
assertTrue(service.contains("requiresOutbound && (!StringUtils.hasText(deliveryMethod)"
+ " || !DELIVERY_METHODS.contains(deliveryMethod))"));
}
}
@@ -0,0 +1,36 @@
package com.baiyue.lastleg.returnreceipt.service;
import com.google.zxing.common.BitArray;
import com.google.zxing.oned.Code128Reader;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
class ReturnCartonBarcodeTest {
@ParameterizedTest
@ValueSource(strings = {"RC-WH-20260912-480898", "RC-WH-20260912-274178", "RC-1", "RC-MERGED"})
void decodesTheActualRenderedSvgBarsToTheExactCartonMark(String mark) throws Exception {
String svg = ReturnCartonBarcode.svg(mark);
var viewBox = Pattern.compile("viewBox=\"0 0 (\\d+) 50\"").matcher(svg);
assertThat(viewBox.find()).isTrue();
int width = Integer.parseInt(viewBox.group(1));
BitArray scanline = new BitArray(width);
var bars = Pattern.compile("<rect x=\"(\\d+)\" y=\"0\" width=\"(\\d+)\" height=\"50\" fill=\"black\"/>").matcher(svg);
int firstBar = width;
int lastBar = 0;
while (bars.find()) {
int start = Integer.parseInt(bars.group(1));
int end = start + Integer.parseInt(bars.group(2));
firstBar = Math.min(firstBar, start);
lastBar = Math.max(lastBar, end);
scanline.setRange(start, end);
}
assertThat(firstBar).isGreaterThanOrEqualTo(10);
assertThat(width - lastBar).isGreaterThanOrEqualTo(10);
assertThat(new Code128Reader().decodeRow(0, scanline, null).getText()).isEqualTo(mark);
assertThat(svg).doesNotContain("background", "repeating-linear-gradient");
}
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.returnreceipt.service;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class ReturnCartonQueryTest {
@Test
void bothQueriesUseExactCustomerScopeAndSkuExistenceWithoutAnOuterJoin() throws Exception {
var query = new ReturnInventoryRequests.Query(2, 1, "42", "SKU", "RC", "AVAILABLE", "RT");
for (String name : new String[]{"merchantCount", "merchantPage"}) {
var method = java.util.Arrays.stream(ReturnInventoryCartonMapper.class.getMethods())
.filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow();
String script = String.join(" ", method.getAnnotation(Select.class).value());
var source = new XMLLanguageDriver().createSqlSource(new Configuration(), script, Map.class);
var bound = source.getBoundSql(Map.of("customerId", "10001", "query", query, "offset", 1L));
String sql = bound.getSql().replaceAll("\\s+", " ");
assertThat(sql).contains("WHERE c.customer_id=?", "AND EXISTS(SELECT 1 FROM ll_return_inventory_carton_item");
assertThat(sql).doesNotContain("customer_id LIKE", " JOIN ");
assertThat(bound.getParameterMappings().get(0).getProperty()).isEqualTo("customerId");
if (name.equals("merchantPage")) assertThat(sql).contains("ORDER BY c.created_at DESC,c.id DESC LIMIT ?,?");
else assertThat(sql).startsWith("SELECT COUNT(*) FROM ll_return_inventory_carton");
}
}
}
@@ -0,0 +1,175 @@
package com.baiyue.lastleg.returnreceipt.service;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
import com.baiyue.lastleg.returnreceipt.entity.ReturnReceipt;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryMovementMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryOperationMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryQueryMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ReturnInventoryServiceTest {
@Mock ReturnInventoryCartonMapper cartons;
@Mock ReturnInventoryCartonItemMapper cartonItems;
@Mock ReturnInventoryBalanceMapper balances;
@Mock ReturnInventoryOperationMapper operations;
@Mock ReturnInventoryQueryMapper queries;
@Mock ReturnInventoryMovementMapper movements;
@Mock LastlegOrderNoGenerator numbers;
@Mock ReturnOrderMapper orders;
@Mock ReturnReceiptMapper receipts;
private ReturnInventoryService service;
@BeforeEach
void setUp() {
service = new ReturnInventoryService();
ReflectionTestUtils.setField(service, "cartons", cartons);
ReflectionTestUtils.setField(service, "cartonItems", cartonItems);
ReflectionTestUtils.setField(service, "balances", balances);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "queries", queries);
ReflectionTestUtils.setField(service, "movements", movements);
ReflectionTestUtils.setField(service, "numbers", numbers);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "receiptMapper", receipts);
}
@Test
void printableLabelUsesBusinessNumbersAndShowsMissingDimensionsWithoutNullOrInternalIds() {
ReturnInventoryCarton carton = new ReturnInventoryCarton();
carton.setId(31L);
carton.setCartonMark("RC-WH-20260912-480898");
carton.setCustomerId("2098676663424974850");
carton.setOverseasWarehouseId("193000000000200003");
carton.setSourceReturnNo("RT-1");
carton.setSourceBatchNo("RB-1,RB-2");
carton.setTotalQuantity(2L);
carton.setSkuTypes(1);
when(cartons.findCustomerMark(carton.getCustomerId(), carton.getCartonMark())).thenReturn(carton);
when(cartonItems.listCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L)));
String html = service.label(carton.getCustomerId(), carton.getCartonMark()).printableHtml();
assertThat(html).contains("<svg", "RT-1", "RB-1,RB-2", "—", "box-sizing:border-box");
assertThat(html).doesNotContain("repeating-linear-gradient", "null", carton.getCustomerId(), carton.getOverseasWarehouseId());
}
@Test
void cartonPageCountsPhysicalCartonsAndKeepsTheAuthenticatedCustomerScope() {
var query = new ReturnInventoryRequests.Query(2, 1, "42", "SKU", null, "AVAILABLE", "RT-1");
ReturnInventoryCarton carton = new ReturnInventoryCarton();
carton.setCartonMark("RC-SECOND");
carton.setSkuTypes(2);
carton.setTotalQuantity(5L);
when(cartons.merchantCount("10001", query)).thenReturn(2L);
when(cartons.merchantPage("10001", query, 1L)).thenReturn(List.of(carton));
var result = service.cartonPage("10001", query);
assertThat(result.total()).isEqualTo(2);
assertThat(result.records()).hasSize(1);
assertThat(result.records().get(0).skuTypes()).isEqualTo(2);
assertThat(result.records().get(0).totalQuantity()).isEqualTo(5);
verify(cartons).merchantPage("10001", query, 1L);
}
@Test
void receiptCartonKeepsTheSourceReturnNumber() {
ReturnOrder order = completedOrder();
ReturnReceipt receipt = new ReturnReceipt();
receipt.setId(21L);
receipt.setReceiptBatchNo("RB-1");
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-1");
service.createReceiptCarton(order, receipt, null,
List.of(new ReturnInventoryService.ReceiptLine("SKU-A", "商品A", 2)));
ArgumentCaptor<ReturnInventoryCarton> captor = ArgumentCaptor.forClass(ReturnInventoryCarton.class);
verify(cartons).insert(captor.capture());
assertThat(captor.getValue().getSourceReturnNo()).isEqualTo("RT-1");
}
@Test
void mergeMovesItemsBetweenCartonsWithoutReducingAvailableQuantity() {
ReturnOrder order = completedOrder();
ReturnReceipt receipt = new ReturnReceipt();
receipt.setId(21L);
ReturnInventoryCarton source = new ReturnInventoryCarton();
source.setId(31L);
source.setCustomerId("10001");
source.setOverseasWarehouseId("42");
source.setCartonMark("RC-SOURCE");
source.setSourceBatchNo("RB-1");
source.setSourceReturnNo("RT-1");
source.setStatus(ReturnInventoryService.AVAILABLE);
source.setSkuTypes(2);
source.setTotalQuantity(4L);
ReturnInventoryCartonItem skuA = item(41L, "SKU-A", 2L);
ReturnInventoryCartonItem skuB = item(42L, "SKU-B", 2L);
when(orders.findByNos("42", List.of("RT-1"))).thenReturn(List.of(order));
when(receipts.listOrder(11L)).thenReturn(List.of(receipt));
when(cartons.findReceipt(21L)).thenReturn(source);
when(cartons.lockMark("RC-SOURCE")).thenReturn(source);
when(cartonItems.listCarton(31L)).thenReturn(List.of(skuA, skuB));
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-MERGED");
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
service.merge(new PdaWarehouseIdentity("operator-1", "操作员", 42L),
new ReturnInventoryRequests.Merge(List.of("RT-1"), List.of(
new ReturnInventoryRequests.ValidItem("SKU-A", 1),
new ReturnInventoryRequests.ValidItem("SKU-B", 1)),
BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, "merge-key"));
verify(balances).adjustCartonQuantity("10001", "42", "SKU-A", 1L);
verify(balances).adjustCartonQuantity("10001", "42", "SKU-B", 1L);
assertThat(source.getTotalQuantity()).isEqualTo(2L);
assertThat(source.getStatus()).isEqualTo(ReturnInventoryService.AVAILABLE);
ArgumentCaptor<ReturnInventoryCarton> target = ArgumentCaptor.forClass(ReturnInventoryCarton.class);
verify(cartons).insert(target.capture());
assertThat(target.getValue().getSourceBatchNo()).isEqualTo("RB-1");
assertThat(target.getValue().getSourceReturnNo()).isEqualTo("RT-1");
}
private ReturnOrder completedOrder() {
ReturnOrder order = new ReturnOrder();
order.setId(11L);
order.setCustomerId("10001");
order.setReturnNo("RT-1");
order.setOverseasWarehouseId("42");
order.setStatus("COMPLETED");
return order;
}
private ReturnInventoryCartonItem item(long id, String sku, long quantity) {
ReturnInventoryCartonItem item = new ReturnInventoryCartonItem();
item.setId(id);
item.setCartonId(31L);
item.setCustomerId("10001");
item.setSkuCode(sku);
item.setProductNameZh(sku);
item.setQuantity(quantity);
return item;
}
}
@@ -0,0 +1,276 @@
package com.baiyue.lastleg.returnreceipt.service;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptRequests;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrderItem;
import com.baiyue.lastleg.returnreceipt.entity.ReturnReceipt;
import com.baiyue.lastleg.returnreceipt.entity.ReturnReceiptBatch;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptBatchMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ReturnReceiptServiceTest {
@Mock ReturnOrderMapper orders;
@Mock ReturnOrderItemMapper orderItems;
@Mock ReturnReceiptMapper receipts;
@Mock ReturnReceiptItemMapper receiptItems;
@Mock ReturnReceiptBatchMapper batches;
@Mock ReturnInventoryService inventory;
@Mock LastlegOrderNoGenerator numbers;
@Mock LastlegOrderMapper lastlegOrders;
ReturnReceiptService service;
PdaWarehouseIdentity identity;
@BeforeEach
void setUp() {
service = new ReturnReceiptService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderItems", orderItems);
ReflectionTestUtils.setField(service, "receipts", receipts);
ReflectionTestUtils.setField(service, "receiptItems", receiptItems);
ReflectionTestUtils.setField(service, "batches", batches);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "numbers", numbers);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "lastlegOrders", lastlegOrders);
identity = new PdaWarehouseIdentity("operator-1", "仓管员", 10L);
}
@Test
void startReturnsOnlyRemainingSkuLinesForTheBoundWarehouse() {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PARTIALLY_RECEIVED, 8, 3);
ReturnOrderItem remaining = item(11L, "SKU-A", 5, 2);
ReturnOrderItem completed = item(12L, "SKU-B", 3, 3);
when(orders.findActiveNo("10", "RT-1")).thenReturn(order);
when(orderItems.listOrder(1L)).thenReturn(List.of(remaining, completed));
var result = service.start(identity, new PdaReturnReceiptRequests.Start(" RT-1 "));
assertEquals("RT-1", result.returnNo());
assertEquals(ReturnReceiptService.PARTIALLY_RECEIVED, result.status());
assertEquals(List.of("SKU-A"), result.expectedItems().stream().map(item -> item.skuCode()).toList());
assertEquals(2, result.expectedItems().get(0).receivedQuantity());
}
@Test
void createForecastPersistsCompleteTailOrderSourceAndReturnsItsBusinessNumber() {
AtomicReference<ReturnOrder> saved = new AtomicReference<>();
when(orders.findKey("customer-1", "create-key")).thenReturn(null);
when(numbers.next(eq("RETURN"), eq("10"), anyLong())).thenReturn("RT-1");
when(numbers.next(eq("RETURN_RELABEL"), eq("10"), anyLong())).thenReturn("RETU-1");
when(orders.insert(any(ReturnOrder.class))).thenAnswer(invocation -> { saved.set(invocation.getArgument(0)); return 1; });
when(orders.findNo("customer-1", "RT-1")).thenAnswer(invocation -> saved.get());
when(orderItems.listOrder(anyLong())).thenReturn(List.of());
var result = service.create("customer-1", "merchant-1", new com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests.Create(
"10", "测试仓", "0000000000", "REF-1", false, "测试", "create-key",
List.of(new com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests.Item("SKU-A", "商品A", 2))));
ArgumentCaptor<LastlegOrder> tail = ArgumentCaptor.forClass(LastlegOrder.class);
verify(lastlegOrders).insert(tail.capture());
assertEquals("RT-1", tail.getValue().getSourceInboundId());
assertEquals("RETURN_ORDER:RT-1", tail.getValue().getSourceEventId());
assertEquals(saved.get().getRequestHash(), tail.getValue().getSourcePayloadHash());
assertEquals("RETU-1", result.order().lastlegOrderNo());
}
@Test
void skuLookupReturnsOnlyActiveCandidatesFromTheBoundWarehouse() {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_RECEIPT, 5, 1);
ReturnOrderItem item = item(11L, "SKU-A", 5, 1);
when(orderItems.activeSkuCandidates("10", "SKU-A")).thenReturn(List.of(item));
when(orders.selectBatchIds(any())).thenReturn(List.of(order));
var result = service.skuCandidates(identity, new PdaReturnReceiptRequests.SkuLookup(" SKU-A "));
assertEquals("SKU-A", result.skuCode());
assertEquals(1, result.candidates().size());
assertEquals("RT-1", result.candidates().get(0).returnNo());
assertEquals(1, result.candidates().get(0).skuReceivedQuantity());
verify(orderItems).activeSkuCandidates("10", "SKU-A");
}
@Test
void partialReceiptCreatesOneCartonAndBillingBatchAndKeepsOrderOpen() {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_RECEIPT, 5, 1);
ReturnOrderItem first = item(11L, "SKU-A", 3, 1);
ReturnOrderItem second = item(12L, "SKU-B", 2, 0);
stubNewReceipt(order, List.of(first, second), "RC-1");
var result = service.confirm(identity, confirm("RETURN_NO", "TRACK-1", "RC-1", "key-1",
List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1))));
assertFalse(result.fullyMatched());
assertEquals(ReturnReceiptService.PARTIALLY_RECEIVED, result.orderStatus());
assertEquals(2, result.receivedQuantity());
assertEquals(2, first.getReceivedQuantity());
assertEquals(0, second.getReceivedQuantity());
assertEquals(ReturnReceiptService.PARTIALLY_RECEIVED, order.getStatus());
verify(inventory).createReceiptCarton(eq(order), any(ReturnReceipt.class), eq("RC-1"), any());
ArgumentCaptor<ReturnReceipt> savedReceipt = ArgumentCaptor.forClass(ReturnReceipt.class);
verify(receipts).insert(savedReceipt.capture());
ArgumentCaptor<ReturnReceiptBatch> savedBatch = ArgumentCaptor.forClass(ReturnReceiptBatch.class);
verify(batches).insert(savedBatch.capture());
assertEquals(order.getId(), savedBatch.getValue().getReturnOrderId());
assertEquals(savedReceipt.getValue().getId(), savedBatch.getValue().getReceiptId());
assertEquals(order.getCustomerId(), savedBatch.getValue().getCustomerId());
assertEquals(order.getOverseasWarehouseId(), savedBatch.getValue().getOverseasWarehouseId());
assertEquals(result.receiptBatchNo(), savedBatch.getValue().getBatchNo());
assertEquals("PENDING_PRICING", savedBatch.getValue().getStorageBillingStatus());
assertNotNull(savedBatch.getValue().getStorageStartedAt());
assertNotNull(savedBatch.getValue().getCreatedAt());
}
@Test
void finalReceiptCompletesTheOrderAndIdempotentReplayDoesNotWriteTwice() {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PARTIALLY_RECEIVED, 2, 1);
ReturnOrderItem line = item(11L, "SKU-A", 2, 1);
stubNewReceipt(order, List.of(line), "RC-AUTO");
var request = confirm("SKU", null, null, "key-final",
List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)));
var first = service.confirm(identity, request);
ArgumentCaptor<ReturnReceipt> savedReceipt = ArgumentCaptor.forClass(ReturnReceipt.class);
verify(receipts).insert(savedReceipt.capture());
when(receipts.findKey("operator-1", "key-final")).thenReturn(savedReceipt.getValue());
when(orders.selectById(1L)).thenReturn(order);
var replay = service.confirm(identity, request);
assertTrue(first.fullyMatched());
assertEquals(ReturnReceiptService.COMPLETED, first.orderStatus());
assertEquals(first.receiptNo(), replay.receiptNo());
assertEquals("RC-AUTO", replay.cartonMark());
verify(inventory, times(1)).createReceiptCarton(any(), any(), any(), any());
verify(batches, times(1)).insert(any(ReturnReceiptBatch.class));
verify(receipts, times(1)).insert(any(ReturnReceipt.class));
}
@Test
void rejectsOverReceiptBeforeCreatingReceiptInventoryOrBatch() {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PARTIALLY_RECEIVED, 2, 1);
ReturnOrderItem line = item(11L, "SKU-A", 2, 1);
when(receipts.findKey("operator-1", "key-over")).thenReturn(null);
when(orders.lockNo("RT-1")).thenReturn(order);
when(orderItems.lockOrder(1L)).thenReturn(List.of(line));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.confirm(identity,
confirm("RETURN_NO", null, null, "key-over", List.of(new PdaReturnReceiptRequests.Item("SKU-A", 2)))));
assertEquals(409, error.getStatusCode().value());
verify(receipts, never()).insert(any(ReturnReceipt.class));
verifyNoInteractions(inventory, batches);
}
@Test
void rejectsAnotherWarehousesOrderBeforeLockingSkuOrWritingInventory() {
ReturnOrder order = order("RT-1", "11", ReturnReceiptService.PENDING_RECEIPT, 2, 0);
when(receipts.findKey("operator-1", "key-cross")).thenReturn(null);
when(orders.lockNo("RT-1")).thenReturn(order);
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.confirm(identity,
confirm("RETURN_NO", null, null, "key-cross", List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)))));
assertEquals(404, error.getStatusCode().value());
verify(orderItems, never()).lockOrder(any());
verifyNoInteractions(inventory, batches);
}
@Test
void rejectsReuseOfAnIdempotencyKeyForDifferentReceiptContent() {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_RECEIPT, 2, 0);
ReturnOrderItem line = item(11L, "SKU-A", 2, 0);
stubNewReceipt(order, List.of(line), "RC-1");
var original = confirm("RETURN_NO", "TRACK-1", "RC-1", "key-1",
List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)));
service.confirm(identity, original);
ArgumentCaptor<ReturnReceipt> savedReceipt = ArgumentCaptor.forClass(ReturnReceipt.class);
verify(receipts).insert(savedReceipt.capture());
when(receipts.findKey("operator-1", "key-1")).thenReturn(savedReceipt.getValue());
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.confirm(identity,
confirm("RETURN_NO", "TRACK-CHANGED", "RC-1", "key-1",
List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)))));
assertEquals(409, error.getStatusCode().value());
verify(inventory, times(1)).createReceiptCarton(any(), any(), anyString(), any());
verify(batches, times(1)).insert(any(ReturnReceiptBatch.class));
}
private void stubNewReceipt(ReturnOrder order, List<ReturnOrderItem> items, String cartonMark) {
when(receipts.findKey(eq("operator-1"), anyString())).thenReturn(null);
when(orders.lockNo("RT-1")).thenReturn(order);
when(orderItems.lockOrder(1L)).thenReturn(items);
when(orderItems.listOrder(1L)).thenReturn(items);
when(numbers.next(eq("RETURN_RECEIPT"), eq("10"), anyLong())).thenReturn("RR-1");
when(numbers.next(eq("RETURN_BATCH"), eq("10"), anyLong())).thenReturn("RB-1");
ReturnInventoryCarton carton = new ReturnInventoryCarton();
carton.setCartonMark(cartonMark);
when(inventory.createReceiptCarton(eq(order), any(ReturnReceipt.class), any(), any())).thenReturn(carton);
}
private static ReturnOrder order(String returnNo, String warehouseId, String status, long expected, long received) {
ReturnOrder order = new ReturnOrder();
order.setId(1L);
order.setCustomerId("customer-1");
order.setReturnNo(returnNo);
order.setCustomerReferenceNo("REF-1");
order.setOverseasWarehouseId(warehouseId);
order.setStatus(status);
order.setExpectedQuantity(expected);
order.setReceivedQuantity(received);
return order;
}
private static ReturnOrderItem item(long id, String sku, long expected, long received) {
ReturnOrderItem item = new ReturnOrderItem();
item.setId(id);
item.setReturnOrderId(1L);
item.setCustomerId("customer-1");
item.setSkuCode(sku);
item.setProductNameZh("商品 " + sku);
item.setExpectedQuantity(expected);
item.setReceivedQuantity(received);
return item;
}
private static PdaReturnReceiptRequests.Confirm confirm(String startedBy, String trackingNo, String cartonMark,
String key, List<PdaReturnReceiptRequests.Item> items) {
return new PdaReturnReceiptRequests.Confirm("RT-1", trackingNo, startedBy, cartonMark, items, key);
}
}
@@ -32,7 +32,7 @@ class Phase5OverseasUpgradeSqlContractTest {
assertThat(overseas.indexOf(table)).as(table + " first reference")
.isEqualTo(overseas.indexOf(create) + "CREATE TABLE IF NOT EXISTS ".length());
}
assertThat(overseas).contains("<> 24", "五期完整业务表集合未部署");
assertThat(overseas).contains("<> 23", "五期完整业务表集合未部署");
}
@Test
@@ -49,8 +49,22 @@ class Phase5OverseasUpgradeSqlContractTest {
"ALTER TABLE ll_delivery_order ADD COLUMN overseas_warehouse_name",
"ALTER TABLE ll_delivery_order ADD COLUMN waybill_no",
"ALTER TABLE ll_dropship_order_profile ADD COLUMN source_pallet_no",
"ALTER TABLE ll_dropship_order_profile ADD COLUMN manifest_version",
"ALTER TABLE ll_dropship_order_profile ADD COLUMN manifest_package_count",
"ALTER TABLE ll_dropship_order_profile ADD COLUMN manifest_fragment_count",
"ALTER TABLE ll_dropship_order_profile ADD COLUMN manifest_hash",
"ALTER TABLE ll_inbound_plan_pallet ADD COLUMN version",
"ADD KEY idx_ll_dropship_profile_manifest(source_pallet_no,manifest_hash)",
"ADD UNIQUE KEY uk_ll_dropship_sku_inventory(profile_id,sku_code)",
"MODIFY COLUMN source_pallet_no VARCHAR(128) NOT NULL");
int relabelCreateEnd = overseas.indexOf(") ENGINE=InnoDB COMMENT='换标货物冻结明细';");
int relabelModify = overseas.indexOf("MODIFY COLUMN destination_fba_warehouse_code", relabelCreateEnd);
for (String column : List.of("destination_fba_warehouse_code", "source_inventory_balance_id",
"source_inventory_position_id", "source_inbound_order_id")) {
assertThat(overseas.indexOf("ALTER TABLE ll_relabel_item ADD COLUMN " + column, relabelCreateEnd))
.as(column + " converges before grouped MODIFY")
.isBetween(relabelCreateEnd, relabelModify - 1);
}
int start = overseas.indexOf("-- 五期正式结构必须由海外固定入口自行补齐");
int end = overseas.indexOf("USE baiyue_lastleg;", start);
String phase5 = overseas.substring(start, end);
@@ -69,6 +83,27 @@ class Phase5OverseasUpgradeSqlContractTest {
}
}
@Test
void overseasFixedAdminProjectionAssertionMatchesItsAuthorityArray() throws Exception {
String overseas = repositoryFile("deploy/sql/海外区域统一增量-MySQL8.sql");
int start = overseas.indexOf("SET @platform_admin_overseas_authorities = JSON_ARRAY(");
int end = overseas.indexOf(");", start);
String authorityArray = overseas.substring(start, end);
int authorityCount = count(authorityArray, "'platform:");
assertThat(authorityCount).isPositive();
assertThat(overseas).contains("JSON_LENGTH(authorities_json) <> " + authorityCount);
}
@Test
void overseasReturnOrderBackfillNormalizesLegacyColumnCollations() throws Exception {
String overseas = repositoryFile("deploy/sql/海外区域统一增量-MySQL8.sql");
assertThat(overseas).contains(
"o.source_order_id COLLATE utf8mb4_unicode_ci = CAST(r.id AS CHAR) COLLATE utf8mb4_unicode_ci",
"o.source_order_no COLLATE utf8mb4_unicode_ci = r.return_no COLLATE utf8mb4_unicode_ci");
}
@Test
void domesticFixedEntryKeepsIamPermissionsButOwnsNoLastlegBusinessSchema() throws Exception {
String domestic = repositoryFile("deploy/sql/头程数据库统一增量-MySQL8.sql");
@@ -91,7 +91,8 @@ class FbaWarehouseValidatorTest {
return new DomesticWarehouseDirectoryClient.Detail(
new DomesticWarehouseDirectoryClient.Warehouse(193000000000000001L, 0L, code, name,
type, "US", status),
List.of(new DomesticWarehouseDirectoryClient.Address("CA")));
List.of(new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "CA", "Los Angeles",
null, "90001", "123 Logistics Ave", null)));
}
private void assertInvalid(org.assertj.core.api.ThrowableAssert.ThrowingCallable action) {
@@ -18,6 +18,8 @@ import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -64,6 +66,7 @@ class LastlegOrderServiceTest {
@Mock InboundOrderDetainedOutboxService detainedOutbox;
@Mock ReceivingWarehouseOperationMapper receivingWarehouseOperations;
@Mock WarehouseResourceGateway warehouseResourceGateway;
@Mock ContainerShippingGateMapper containerShippingGates;
LastlegOrderService service;
@BeforeEach
@@ -85,6 +88,7 @@ class LastlegOrderServiceTest {
ReflectionTestUtils.setField(service, "detainedOutbox", detainedOutbox);
ReflectionTestUtils.setField(service, "receivingWarehouseOperations", receivingWarehouseOperations);
ReflectionTestUtils.setField(service, "warehouseResourceGateway", warehouseResourceGateway);
ReflectionTestUtils.setField(service, "containerShippingGates", containerShippingGates);
}
@Test
@@ -158,6 +162,26 @@ class LastlegOrderServiceTest {
.noneMatch(component -> "eventId".equals(component.getName())));
}
@Test
void detailReadsContainerNodeFromTheContainerShippingGate() {
LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null);
order.setSourceContainerBookingId("BOOKING-1");
ContainerShippingGate gate = new ContainerShippingGate();
gate.setContainerBookingId("BOOKING-1");
gate.setStandardNode("PICKUP_READY");
when(orders.selectOne(any())).thenReturn(order);
when(details.selectList(any())).thenReturn(List.of());
when(histories.selectList(any())).thenReturn(List.of());
when(feeSources.listForBusiness("100", "LASTLEG_ORDER", 11L)).thenReturn(List.of());
when(operations.selectList(any())).thenReturn(List.of());
when(manualNodes.selectList(any())).thenReturn(List.of());
when(containerShippingGates.selectById("BOOKING-1")).thenReturn(gate);
var response = service.detail("100", 11L);
assertEquals("PICKUP_READY", response.order().containerNode());
}
@Test
void transferUpdatesOrderAndEveryCargoSnapshot() {
LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null);
@@ -26,7 +26,7 @@ class LoadingCompletedConsumerTest {
assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id"));
}
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); addManifest(node.path("orders").get(0).path("pallets").get(0),2); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
@Test void freezesReceivingWarehouseInInitialInTransitInventory(){
service.consume(event());
ArgumentCaptor<InventoryCommandRequests.InTransit> captured=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class);
@@ -160,6 +160,7 @@ class LoadingCompletedConsumerTest {
secondSku.put("sourceInboundDetailId","d-2");
var marks=secondSku.putArray("cartonMarks"); for(int n=6;n<=10;n++)marks.add("RK1#10-"+n);
skus.add(secondSku);
addManifest(order.path("pallets").get(0),10);
service.consume(rehash(original,node));
var captured=ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class);
verify(cartons,times(10)).insert(captured.capture());
@@ -193,7 +194,7 @@ class LoadingCompletedConsumerTest {
sourceOrder.put("orderType","ONE_CLICK_DROPSHIP").put("destinationType","OVERSEAS");
var sku=json.createObjectNode().put("sourceInboundDetailId","d-1").put("skuCode","SKU-1").put("productName","产品").put("packageCount",5).put("productQuantity",50);
var marks=sku.putArray("cartonMarks"); for(int n=1;n<=5;n++)marks.add("RK1#5-"+n);
var pallet=json.createObjectNode().put("palletNo","DP-260909-0143"); pallet.putArray("skuPackages").add(sku);
var pallet=json.createObjectNode().put("palletNo","DP-260909-0143"); pallet.putArray("skuPackages").add(sku); addManifest(pallet,5);
sourceOrder.putArray("pallets").add(pallet);
if(duplicateSku) ((com.fasterxml.jackson.databind.node.ArrayNode)sourceOrder.path("items")).add(sourceOrder.path("items").get(0).deepCopy());
node.putArray("orders").add(sourceOrder);
@@ -202,5 +203,6 @@ class LoadingCompletedConsumerTest {
}
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); }
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); 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 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){ var object=(com.fasterxml.jackson.databind.node.ObjectNode)pallet; object.put("manifestVersion",1).put("manifestPackageCount",packageCount).put("manifestFragmentCount",1).put("manifestHash","a".repeat(64)); }
}