Merge test into dev and resolve conflicts
This commit is contained in:
+2
-1
@@ -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) {}
|
||||
}
|
||||
+24
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -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);
|
||||
}
|
||||
+144
@@ -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) {}
|
||||
}
|
||||
+13
-3
@@ -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) {}
|
||||
|
||||
+4
@@ -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;
|
||||
|
||||
+8
@@ -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'")
|
||||
|
||||
+8
@@ -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>" +
|
||||
|
||||
+15
@@ -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;
|
||||
|
||||
+7
@@ -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,
|
||||
|
||||
+8
-2
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
+10
-2
@@ -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}")
|
||||
|
||||
+1
-1
@@ -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));
|
||||
}
|
||||
|
||||
+9
-1
@@ -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,
|
||||
|
||||
+47
-7
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
@@ -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;
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
+6
@@ -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));
|
||||
|
||||
+65
-2
@@ -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); }
|
||||
|
||||
+318
-55
@@ -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());
|
||||
|
||||
+18
-5
@@ -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
|
||||
|
||||
+67
-10
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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} " +
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
|
||||
+3
@@ -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());
|
||||
|
||||
+1
-1
@@ -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)) {
|
||||
|
||||
+7
@@ -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,
|
||||
|
||||
+5
@@ -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) { }
|
||||
|
||||
+7
@@ -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>")
|
||||
|
||||
+15
@@ -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")
|
||||
|
||||
+26
@@ -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();
|
||||
}
|
||||
}
|
||||
+26
-7
@@ -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); } }
|
||||
|
||||
+3
-1
@@ -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)
|
||||
|
||||
+32
-6
@@ -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 }
|
||||
|
||||
+2
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -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);
|
||||
|
||||
+7
@@ -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() {
|
||||
|
||||
+6
-2
@@ -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
|
||||
|
||||
+78
@@ -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"));
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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))));
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
+1
@@ -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"));
|
||||
|
||||
+10
-4
@@ -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"));
|
||||
|
||||
+19
@@ -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");
|
||||
|
||||
+91
-28
@@ -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);
|
||||
|
||||
+22
-20
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+25
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -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
|
||||
|
||||
+1
-1
@@ -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));
|
||||
|
||||
+21
@@ -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))"));
|
||||
}
|
||||
}
|
||||
|
||||
+36
@@ -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");
|
||||
}
|
||||
}
|
||||
+32
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
@@ -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;
|
||||
}
|
||||
}
|
||||
+276
@@ -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);
|
||||
}
|
||||
}
|
||||
+36
-1
@@ -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");
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
+5
-3
@@ -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)); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user