Merge test into dev and resolve conflicts

This commit is contained in:
2026-09-14 11:02:37 +08:00
342 changed files with 8454 additions and 1591 deletions
@@ -22,5 +22,6 @@ public interface DomesticWarehouseDirectoryClient {
record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName,
String warehouseType, String countryCode, String status) { }
record Address(String stateProvince) { }
record Address(String addressType, String countryCode, String stateProvince, String city,
String district, String postalCode, String addressLine1, String addressLine2) { }
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.client;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
@FeignClient(name = "print-service", contextId = "lastlegPrintPdfClient",
url = "${baiyue.clients.print.url:}", path = "/internal/print/v1/pdfs")
public interface PrintPdfClient {
@PostMapping(value = "/dispatch-bol-documents", consumes = "application/json")
ResponseEntity<byte[]> generateDispatchBol(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
@RequestBody Request request);
record Request(String templateCode, PageSpec pageSpec, String downloadFileName, List<Document> documents) {}
record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {}
record Document(String shipFromName, String shipFromAddress, String carrierName, String isaNumber,
String appointmentTime, String trailerNumber, String sealNumber, String fbaWarehouseCode,
String shipToName, String shipToAddress, String dispatchNo, String fbaShipmentIds,
String poReferenceNos, int totalPackages, int pageNumber, int pageCount, List<CargoRow> cargoRows) {}
record CargoRow(String containerNo, String palletNo, String fbaWarehouseCode, String warehouseNo,
String fbaShipmentId, String poReferenceNo, int packageCount) {}
}
@@ -6,6 +6,7 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import com.baiyue.lastleg.dispatch.service.DispatchService;
import com.baiyue.lastleg.dispatch.service.DispatchBolPrintService;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -13,6 +14,11 @@ import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
@@ -20,6 +26,8 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import jakarta.servlet.http.HttpServletRequest;
import java.nio.charset.StandardCharsets;
@RestController
@ConditionalOnProperty(name = "lastleg.dispatch-query-api.enabled", havingValue = "true")
@@ -27,6 +35,7 @@ import org.springframework.web.server.ResponseStatusException;
@Tag(name = "Tail-leg vehicle dispatch query")
public class DispatchQueryController {
@Resource private DispatchService service;
@Resource private DispatchBolPrintService printService;
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
@@ -48,6 +57,21 @@ public class DispatchQueryController {
return ApiResponse.success(service.platformDetail(dispatchNo));
}
@GetMapping(value = "/{dispatchNo}/prints/bol", produces = MediaType.APPLICATION_PDF_VALUE)
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
@Operation(summary = "Generate dispatch BOL as an A4 PDF")
public ResponseEntity<byte[]> printBol(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo, HttpServletRequest request) {
requirePlatform(user);
DispatchBolPrintService.GeneratedPdf pdf = printService.generate(dispatchNo, request.getHeader("X-Request-Id"));
ResponseEntity.BodyBuilder response = ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF)
.cacheControl(CacheControl.noStore()).header(HttpHeaders.PRAGMA, "no-cache")
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(pdf.fileName(), StandardCharsets.UTF_8).build().toString())
.header("X-Content-Type-Options", "nosniff");
if (pdf.pageCount() != null) response.header("X-Print-Page-Count", pdf.pageCount());
return response.contentLength(pdf.content().length).body(pdf.content());
}
private void requirePlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
@@ -12,11 +12,16 @@ public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate,
int itemCount, int palletCount, int packageCount, int pickedPackageCount,
int shippedPackageCount, int remainingPackageCount, String partialCompletionReason,
LocalDateTime createdAt,
List<Item> items) {
List<Item> items, List<BolLine> bolLines) {
public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo,
String overseasWarehouseId, String overseasWarehouseName,
String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, int packageCount,
BigDecimal volumeCbm, String sourceStage) {
}
public record BolLine(String containerNo, String palletNo, String fbaWarehouseCode,
String destinationWarehouseName, String warehouseNo, String fbaShipmentId,
String poReferenceNo, int packageCount) {
}
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface DispatchBolMapper {
@Select("SELECT " +
"COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo," +
"MAX(NULLIF(di.pallet_no,'')) AS palletNo," +
"COALESCE(MAX(NULLIF(l.destination_warehouse_code,'')),MAX(NULLIF(di.fba_warehouse_code,''))," +
"MAX(NULLIF(di.destination_warehouse_code,''))) AS fbaWarehouseCode," +
"COALESCE(MAX(NULLIF(l.destination_warehouse_name,'')),MAX(NULLIF(di.destination_warehouse_name,''))) AS destinationWarehouseName," +
"COALESCE(MAX(NULLIF(l.inbound_code,'')),MAX(NULLIF(di.inbound_code,''))) AS warehouseNo," +
"GROUP_CONCAT(DISTINCT NULLIF(od.shipment_no,'') ORDER BY od.shipment_no SEPARATOR ', ') AS fbaShipmentId," +
"GROUP_CONCAT(DISTINCT NULLIF(od.fba_tracking_no,'') ORDER BY od.fba_tracking_no SEPARATOR ', ') AS poReferenceNo," +
"CASE WHEN MAX(l.id) IS NULL THEN COALESCE(MAX(di.package_count),0) " +
"WHEN COALESCE(MAX(a.picked_package_count),0)>0 THEN MAX(a.picked_package_count) " +
"ELSE COALESCE(MAX(a.planned_package_count),0) END AS packageCount " +
"FROM ll_dispatch_item di " +
"LEFT JOIN ll_inbound_plan_pallet p ON p.id=di.plan_pallet_id " +
"LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id " +
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id " +
"LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id " +
"LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=di.customer_id " +
"WHERE di.customer_id=#{customerId} AND di.dispatch_order_id=#{dispatchOrderId} " +
"GROUP BY di.id,l.id ORDER BY di.id,l.id")
List<DispatchResponse.BolLine> findByDispatchOrder(@Param("customerId") String customerId,
@Param("dispatchOrderId") Long dispatchOrderId);
}
@@ -0,0 +1,144 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
import com.baiyue.lastleg.client.PrintPdfClient;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import feign.FeignException;
import jakarta.annotation.Resource;
import java.math.BigDecimal;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
@Service
public class DispatchBolPrintService {
private static final String TEMPLATE = "DISPATCH_BILL_OF_LADING_A4_V2";
private static final int FIRST_PAGE_ROWS = 18;
private static final int CONTINUATION_PAGE_ROWS = 26;
private static final PrintPdfClient.PageSpec A4 = new PrintPdfClient.PageSpec(new BigDecimal("210"), new BigDecimal("297"));
@Resource private DispatchService dispatches;
@Resource private PrintPdfClient print;
@Resource private DomesticWarehouseDirectoryClient warehouses;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
public GeneratedPdf generate(String dispatchNo, String requestId) {
DispatchResponse detail = dispatches.platformDetail(dispatchNo);
if (detail.bolLines() == null || detail.bolLines().isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "排车单没有可打印的BOL明细");
}
List<PrintPdfClient.Document> documents = documents(detail);
String fileName = safeFilePart(detail.dispatchNo()) + "-BOL.pdf";
try {
ResponseEntity<byte[]> response = print.generateDispatchBol(internalToken, requestId,
new PrintPdfClient.Request(TEMPLATE, A4, fileName, documents));
byte[] content = response.getBody();
MediaType contentType = response.getHeaders().getContentType();
if (!response.getStatusCode().is2xxSuccessful() || content == null || content.length == 0
|| contentType == null || !MediaType.APPLICATION_PDF.isCompatibleWith(contentType)) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务未返回有效PDF");
}
return new GeneratedPdf(content, fileName, response.getHeaders().getFirst("X-Print-Page-Count"));
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用");
}
}
private List<PrintPdfClient.Document> documents(DispatchResponse detail) {
String shipFromAddress = warehouseAddress(detail.overseasWarehouseId());
Map<String, List<DispatchResponse.BolLine>> groups = new LinkedHashMap<>();
for (DispatchResponse.BolLine line : detail.bolLines()) groups.computeIfAbsent(text(line.fbaWarehouseCode()), ignored -> new ArrayList<>()).add(line);
List<PrintPdfClient.Document> documents = new ArrayList<>();
for (Map.Entry<String, List<DispatchResponse.BolLine>> group : groups.entrySet()) {
List<DispatchResponse.BolLine> lines = group.getValue();
int pages = pageCount(lines.size());
int total = lines.stream().mapToInt(DispatchResponse.BolLine::packageCount).sum();
String destination = lines.stream().map(DispatchResponse.BolLine::destinationWarehouseName).filter(v -> v != null && !v.isBlank()).findFirst().orElse("—");
int offset = 0;
for (int page = 0; page < pages; page++) {
int capacity = page == 0 ? FIRST_PAGE_ROWS : CONTINUATION_PAGE_ROWS;
int end = Math.min(lines.size(), offset + capacity);
List<PrintPdfClient.CargoRow> rows = lines.subList(offset, end).stream()
.map(line -> new PrintPdfClient.CargoRow(text(line.containerNo()), text(line.palletNo()), text(line.fbaWarehouseCode()),
text(line.warehouseNo()), text(line.fbaShipmentId()), text(line.poReferenceNo()), line.packageCount())).toList();
documents.add(new PrintPdfClient.Document(text(detail.overseasWarehouseName()), shipFromAddress, text(detail.carrierName()),
text(detail.fbaAppointmentNo()), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—",
group.getKey(), destination, "—", text(detail.dispatchNo()), unique(lines, true), unique(lines, false),
total, page + 1, pages, rows));
offset = end;
}
}
return List.copyOf(documents);
}
private String warehouseAddress(String warehouseId) {
long id;
try {
id = Long.parseLong(warehouseId == null ? "" : warehouseId.strip());
} catch (NumberFormatException exception) {
throw new BizException(ResultCode.VALIDATION_FAILED, "排车单未关联有效的海外仓");
}
try {
var response = warehouses.activeWarehouse(id, internalToken);
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库主数据暂不可用,无法生成BOL");
}
List<DomesticWarehouseDirectoryClient.Address> addresses = response.data().addresses();
if (addresses == null || addresses.isEmpty()) return "—";
DomesticWarehouseDirectoryClient.Address address = addresses.stream().filter(Objects::nonNull)
.filter(value -> "OPERATING".equals(value.addressType())).findFirst()
.orElseGet(() -> addresses.stream().filter(Objects::nonNull).findFirst().orElse(null));
if (address == null) return "—";
List<String> parts = new ArrayList<>();
add(parts, address.addressLine1());
add(parts, address.addressLine2());
String city = trimmed(address.city());
String state = trimmed(address.stateProvince());
String postalCode = trimmed(address.postalCode());
String region = city == null ? state : state == null ? city : city + ", " + state;
if (postalCode != null) region = region == null ? postalCode : region + " " + postalCode;
add(parts, region);
add(parts, address.countryCode());
return parts.isEmpty() ? "—" : String.join(", ", parts);
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库主数据暂不可用,无法生成BOL");
}
}
private void add(List<String> parts, String value) {
if (value != null && !value.isBlank()) parts.add(value.strip());
}
private String trimmed(String value) {
return value == null || value.isBlank() ? null : value.strip();
}
static int pageCount(int rowCount) {
if (rowCount <= FIRST_PAGE_ROWS) return 1;
return 1 + (rowCount - FIRST_PAGE_ROWS + CONTINUATION_PAGE_ROWS - 1) / CONTINUATION_PAGE_ROWS;
}
private String unique(List<DispatchResponse.BolLine> lines, boolean shipment) {
LinkedHashSet<String> values = new LinkedHashSet<>();
for (DispatchResponse.BolLine line : lines) {
String raw = shipment ? line.fbaShipmentId() : line.poReferenceNo();
if (raw != null) for (String value : raw.split(",")) if (!value.isBlank()) values.add(value.strip());
}
return values.isEmpty() ? "—" : String.join(", ", values);
}
private String dateTime(java.time.LocalDateTime value) { return value == null ? "—" : value.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); }
private String text(String value) { return value == null || value.isBlank() ? "—" : value.strip(); }
private String safeFilePart(String value) { return text(value).replaceAll("[\\\\/:*?\"<>|]", "-"); }
public record GeneratedPdf(byte[] content, String fileName, String pageCount) {}
}
@@ -12,6 +12,7 @@ import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
import com.baiyue.lastleg.dispatch.entity.DispatchDraft;
import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
@@ -52,6 +53,7 @@ public class DispatchService {
@Resource private DispatchOrderMapper orders;
@Resource private DispatchItemMapper items;
@Resource private DispatchBolMapper bolLines;
@Resource private DispatchPalletLockMapper locks;
@Resource private DispatchDraftMapper drafts;
@Resource private DispatchDraftContainerMapper draftContainers;
@@ -475,7 +477,8 @@ public class DispatchService {
public DispatchResponse detail(String customerId, String dispatchNo) {
DispatchOrder dispatch = find(customerId, dispatchNo);
return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()),
bolLines.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
}
public DispatchResponse platformDetail(String dispatchNo) {
@@ -483,7 +486,8 @@ public class DispatchService {
if (dispatch == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
}
return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()),
bolLines.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
}
@Transactional
@@ -593,6 +597,11 @@ public class DispatchService {
}
private DispatchResponse response(DispatchOrder order, List<DispatchItem> itemList) {
return response(order, itemList, List.of());
}
private DispatchResponse response(DispatchOrder order, List<DispatchItem> itemList,
List<DispatchResponse.BolLine> bolLineList) {
String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream())
.distinct().collect(Collectors.joining(","));
return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(),
@@ -609,7 +618,8 @@ public class DispatchService {
item.getWarehousePalletId(), item.getPalletNo(), item.getOverseasWarehouseId(), item.getOverseasWarehouseName(),
item.getOrderNo(), item.getOrderRefs(), item.getInboundCode(),
item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
Objects.requireNonNullElse(item.getPackageCount(), 0), item.getVolumeCbm(), item.getSourceStage())).toList());
Objects.requireNonNullElse(item.getPackageCount(), 0), item.getVolumeCbm(), item.getSourceStage())).toList(),
bolLineList == null ? List.of() : bolLineList);
}
private static String route(List<DispatchPalletCandidate> selected) {
@@ -8,7 +8,11 @@ public final class DropshipRequests {
private DropshipRequests() {}
public record Receive(@NotBlank String customerId, @NotNull Long orderId, @NotNull Long warehouseId,
@NotBlank String idempotencyKey, @NotEmpty List<@Valid Pallet> pallets) {}
public record Pallet(@NotBlank String palletNo, @NotEmpty List<@Valid SkuPackages> skuPackages) {}
public record Pallet(@NotBlank String palletNo, @NotEmpty List<@Valid SkuPackages> skuPackages,
@NotNull @Positive Integer manifestVersion,
@NotNull @Positive Integer manifestPackageCount,
@NotNull @Positive Integer manifestFragmentCount,
@NotBlank @Size(max = 64) String manifestHash) {}
public record SkuPackages(@NotBlank String skuCode, String productName, @Positive long packageCount,
@Positive long productQuantity,
@NotEmpty List<@NotBlank String> cartonMarks) {}
@@ -13,6 +13,10 @@ public class DropshipOrderProfile {
private String palletNo;
/** Immutable pallet number snapshot supplied by the first-leg handover event. */
private String sourcePalletNo;
private Integer manifestVersion;
private Integer manifestPackageCount;
private Integer manifestFragmentCount;
private String manifestHash;
private Long inventoryPositionId;
private String status;
private String idempotencyKey;
@@ -17,6 +17,14 @@ public interface DropshipCartonMapper extends BaseMapper<DropshipCarton> {
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,carton_sequence")
List<DropshipCarton> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Select("""
SELECT DISTINCT p.source_pallet_no FROM ll_dropship_carton c
JOIN ll_dropship_order_profile p ON p.id=c.profile_id AND p.customer_id=c.customer_id
JOIN ll_order o ON o.id=c.order_id AND o.customer_id=c.customer_id
WHERE o.container_no=#{containerNo} AND c.carton_mark=#{cartonMark}
""")
List<String> sourcePalletsInContainer(@Param("containerNo") String containerNo,
@Param("cartonMark") String cartonMark);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku} AND status='AVAILABLE' ORDER BY carton_sequence LIMIT #{packages} FOR UPDATE")
List<DropshipCarton> lockAvailable(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages);
@Update("UPDATE ll_dropship_carton SET status='AVAILABLE', outbound_order_no=NULL, updated_at=NOW(3) WHERE customer_id=#{customerId} AND outbound_order_no=#{outboundNo} AND status='SHIPPED'")
@@ -39,6 +39,14 @@ public interface DropshipOrderProfileMapper extends BaseMapper<DropshipOrderProf
List<DropshipOrderProfile> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND inventory_position_id=#{positionId} LIMIT 1")
DropshipOrderProfile findPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId);
@Select("""
SELECT p.* FROM ll_dropship_order_profile p
JOIN ll_order o ON o.id=p.order_id AND o.customer_id=p.customer_id
WHERE o.container_no=#{containerNo} AND p.source_pallet_no=#{sourcePalletNo}
ORDER BY p.customer_id,p.order_id,p.id
""")
List<DropshipOrderProfile> listContainerPallet(@Param("containerNo") String containerNo,
@Param("sourcePalletNo") String sourcePalletNo);
@Select("<script>" + OUTBOUND_CANDIDATE_SELECT + OUTBOUND_CANDIDATE_WHERE +
"<if test='warehouseId != null and warehouseId != \"\"'>AND pos.overseas_warehouse_id=#{warehouseId} </if>" +
@@ -51,6 +51,17 @@ public class DropshipOrderService {
Set<String> requestedPallets = request.pallets().stream().map(value -> value.palletNo().trim())
.collect(java.util.stream.Collectors.toSet());
if (!existingPallets.equals(requestedPallets)) throw conflict("Dropship order pallet snapshot conflicts with existing data");
Map<String, DropshipOrderProfile> byPallet = existing.stream().collect(Collectors.toMap(
DropshipOrderProfile::getSourcePalletNo, value -> value));
for (DropshipRequests.Pallet requested : request.pallets()) {
DropshipOrderProfile stored = byPallet.get(requested.palletNo().trim());
if (!java.util.Objects.equals(stored.getManifestVersion(), requested.manifestVersion())
|| !java.util.Objects.equals(stored.getManifestPackageCount(), requested.manifestPackageCount())
|| !java.util.Objects.equals(stored.getManifestFragmentCount(), requested.manifestFragmentCount())
|| !java.util.Objects.equals(stored.getManifestHash(), requested.manifestHash())) {
throw conflict("Dropship pallet manifest conflicts with existing data: " + requested.palletNo());
}
}
return Math.toIntExact(totalPackages);
}
@@ -104,6 +115,10 @@ public class DropshipOrderService {
DropshipOrderProfile profile = new DropshipOrderProfile();
profile.setId(IdWorker.getId()); profile.setCustomerId(request.customerId()); profile.setOrderId(order.getId());
profile.setSourcePalletNo(input.palletNo().trim());
profile.setManifestVersion(input.manifestVersion());
profile.setManifestPackageCount(input.manifestPackageCount());
profile.setManifestFragmentCount(input.manifestFragmentCount());
profile.setManifestHash(input.manifestHash().trim().toLowerCase(java.util.Locale.ROOT));
profile.setStatus("PENDING_INBOUND"); profile.setVersion(0L);
profile.setIdempotencyKey(request.idempotencyKey() + ":" + input.palletNo().trim());
profile.setCreatedAt(now); profile.setUpdatedAt(now);
@@ -29,6 +29,8 @@ public record LastlegOrderSummary(
String transferWarehouseName,
String fbaWarehouseCode,
String containerNo,
@Schema(description = "由柜级船节点门禁读取的最新标准货柜节点")
String containerNode,
Boolean abnormal,
Boolean detained,
Boolean heldChargeable,
@@ -36,7 +36,6 @@ public class LastlegOrder {
private String transferWarehouseId;
private String transferWarehouseCode;
private String transferWarehouseName;
private String containerStatus;
private Boolean abnormal;
private Boolean detained;
private Boolean holdChargeable;
@@ -17,6 +17,8 @@ public record ContainerInboundPlanPreviewResponse(
int blockedOrderCount,
int pendingOrderCount,
int pendingPalletCount,
int sourcePalletCount,
int supplementPalletCount,
int existingPlannedPalletCount,
int totalSuggestedPalletCount,
int totalPlannedPalletCount,
@@ -31,6 +33,8 @@ public record ContainerInboundPlanPreviewResponse(
int orderCount,
int pendingOrderCount,
int pendingPalletCount,
int sourcePalletCount,
int supplementPalletCount,
int existingPlannedPalletCount,
int suggestedPalletCount,
int plannedPalletCount,
@@ -52,6 +56,9 @@ public record ContainerInboundPlanPreviewResponse(
int packageCount,
BigDecimal volumeCbm,
Integer suggestedPalletCount,
int sourcePalletCount,
int targetPalletCount,
int supplementPalletCount,
Integer plannedPalletCount,
String existingPlanNo,
String planStatus,
@@ -10,6 +10,9 @@ public record InboundPlanResponse(
String planNo,
String customerId,
Long sourceOrderId,
String sourceOrderNo,
String customerName,
String orderType,
String containerNo,
String overseasWarehouseCode,
String overseasWarehouseName,
@@ -29,7 +32,9 @@ public record InboundPlanResponse(
boolean mergeRequired,
boolean urgent,
String creationMode,
String remark
String remark,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
@@ -44,7 +49,8 @@ public record InboundPlanResponse(
int actualPackageCount) {
}
public record Allocation(Long lineId, Long palletId, BigDecimal allocatedVolumeCbm,
public record Allocation(Long lineId, Long palletId, String palletNo, String palletOwnerPlanNo,
boolean palletOwnedByCurrentPlan, BigDecimal allocatedVolumeCbm,
int plannedPackageCount, int countedPackageCount, int pickedPackageCount,
BigDecimal actualVolumeCbm, String status, int version) {
}
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Data;
import java.math.BigDecimal;
@@ -31,4 +32,6 @@ public class InboundPlanPallet {
private LocalDateTime lockedAt;
private BigDecimal actualVolumeCbm;
private Integer actualPackageCount;
@Version
private Integer version;
}
@@ -16,6 +16,7 @@ public interface InboundPlanOrderMapper {
String orderType) { }
record ReceivingWarehouseSnapshot(String overseasWarehouseId, String overseasWarehouseCode,
String overseasWarehouseName) { }
record DetailOrderSnapshot(String orderNo, String customerName, String orderType) { }
record ContainerWarehouseRef(String customerId, Long orderId, String sourceContainerBookingId,
String overseasWarehouseId) { }
record ContainerPreviewOrderRow(
@@ -26,7 +27,7 @@ public interface InboundPlanOrderMapper {
String overseasWarehouseName, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName, int destinationCount,
boolean detained, int version, String planNo, String planStatus,
Integer plannedPalletCount, Integer materializedPalletCount
Integer plannedPalletCount, Integer materializedPalletCount, Integer sourcePalletCount
) { }
@Select("SELECT o.customer_id AS customerId,o.id AS orderId,o.order_no AS orderNo," +
@@ -44,7 +45,9 @@ public interface InboundPlanOrderMapper {
"o.detained AS detained,o.version AS version,p.plan_no AS planNo,p.status AS planStatus," +
"p.pallet_count AS plannedPalletCount," +
"(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " +
"AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL) AS materializedPalletCount " +
"AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL) AS materializedPalletCount," +
"(SELECT COUNT(*) FROM ll_order_source_pallet sp WHERE sp.customer_id=o.customer_id " +
"AND sp.order_id=o.id) AS sourcePalletCount " +
"FROM ll_order o LEFT JOIN ll_inbound_plan p ON p.customer_id=o.customer_id AND p.source_order_id=o.id " +
"WHERE o.source_container_booking_id=#{sourceContainerBookingId} ORDER BY o.customer_id,o.id")
List<ContainerPreviewOrderRow> containerPreviewOrders(
@@ -99,6 +102,11 @@ public interface InboundPlanOrderMapper {
ReceivingWarehouseSnapshot findReceivingWarehouse(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
@Select("SELECT order_no AS orderNo,customer_name AS customerName,order_type AS orderType " +
"FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
DetailOrderSnapshot findDetailOrder(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
@Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," +
"pallet_merge_flag='MERGED',pallet_count=#{palletCount},updated_at=#{updatedAt} " +
"WHERE customer_id=#{customerId} AND id=#{orderId}")
@@ -144,7 +144,7 @@ public class ReceivingContainerController {
@PostMapping("/current/complete")
public ApiResponse<ReceivingContainerResponses.Session> complete(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.Action request) {
@Valid @RequestBody ReceivingContainerRequests.Complete request) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.complete(identity.operatorKey(), request));
}
@@ -13,14 +13,22 @@ public final class ReceivingContainerRequests {
private ReceivingContainerRequests() { }
public record Action(@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Complete(boolean allowDifference,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Sign(@NotNull @Size(min = 3, max = 3) List<@Valid SignedPhoto> photos,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record SignedPhoto(@NotNull ReceivingPhotoRole role,
@NotBlank @Pattern(regexp = "^[1-9][0-9]{0,18}$") String fileId) { }
public record CartonResolve(@NotBlank String cartonMark) { }
public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { }
/** packageCount 是该托当前累计实收箱数,不是本次增量。 */
public record BulkReceive(@NotBlank String inboundCode, @NotBlank String palletCode,
@Positive int packageCount, @NotBlank @Size(max = 255) String idempotencyKey) { }
@Positive int packageCount, Integer expectedPalletVersion,
@NotBlank @Size(max = 255) String idempotencyKey) {
public BulkReceive(String inboundCode, String palletCode, int packageCount, String idempotencyKey) {
this(inboundCode, palletCode, packageCount, null, idempotencyKey);
}
}
public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record WholePalletReceive(@NotBlank String sourcePalletNo,
@@ -35,7 +35,7 @@ public final class ReceivingContainerResponses {
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
LocalDateTime receivingAt, LocalDateTime completedAt,
int totalPackages, int receivedPackages, int pendingPackages,
List<Inbound> inbounds) {
boolean hasDifference, List<Inbound> inbounds) {
public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName,
String status, String operatorName, int version, List<SignedPhoto> photos,
LocalDateTime preparedAt, LocalDateTime signedAt, LocalDateTime receivingAt,
@@ -43,7 +43,17 @@ public final class ReceivingContainerResponses {
int pendingPackages, List<Inbound> inbounds) {
this(containerNo, overseasWarehouseId, null, overseasWarehouseName, status, operatorName,
version, photos, preparedAt, signedAt, receivingAt, completedAt, totalPackages,
receivedPackages, pendingPackages, inbounds);
receivedPackages, pendingPackages, false, inbounds);
}
public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseCode,
String overseasWarehouseName, String status, String operatorName, int version,
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
LocalDateTime receivingAt, LocalDateTime completedAt, int totalPackages,
int receivedPackages, int pendingPackages, List<Inbound> inbounds) {
this(containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName,
status, operatorName, version, photos, preparedAt, signedAt, receivingAt,
completedAt, totalPackages, receivedPackages, pendingPackages, false, inbounds);
}
}
@@ -65,7 +75,20 @@ public final class ReceivingContainerResponses {
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages,
boolean assigned, boolean mergeAllowed, boolean urgent,
String mergeHint) {
String mergeHint, String operationMode, String groupKey,
List<String> inboundCodes, String sourcePalletNo) {
public CartonResolved(String cartonMark, String inboundCode, String destinationType,
String overseasWarehouseId, String overseasWarehouseName,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode,
int plannedPalletCount, int totalPackages, int receivedPackages,
int pendingPackages, boolean assigned, boolean mergeAllowed,
boolean urgent, String mergeHint) {
this(cartonMark, inboundCode, destinationType, overseasWarehouseId, overseasWarehouseName,
destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
fbaWarehouseCode, plannedPalletCount, totalPackages, receivedPackages, pendingPackages,
assigned, mergeAllowed, urgent, mergeHint, null, null, List.of(inboundCode), null);
}
public CartonResolved(String cartonMark, String inboundCode, String destinationType,
String overseasWarehouseId, String overseasWarehouseName,
String destinationWarehouseId, String destinationWarehouseCode,
@@ -75,24 +98,41 @@ public final class ReceivingContainerResponses {
this(cartonMark, inboundCode, destinationType, overseasWarehouseId, overseasWarehouseName,
destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
fbaWarehouseCode, plannedPalletCount, totalPackages, receivedPackages,
pendingPackages, false, false, false, null);
pendingPackages, false, false, false, null, null, null, List.of(inboundCode), null);
}
}
public record PalletResolved(String palletCode, long warehouseId, String status,
String fbaWarehouseCode, String inboundCode,
String destinationType, boolean compatible) { }
String destinationType, boolean compatible, int version,
int currentReceivedPackages) {
public PalletResolved(String palletCode, long warehouseId, String status,
String fbaWarehouseCode, String inboundCode,
String destinationType, boolean compatible) {
this(palletCode, warehouseId, status, fbaWarehouseCode, inboundCode,
destinationType, compatible, 0, 0);
}
}
public record Receipt(String result, String containerNo, String inboundCode,
String destinationType, String palletCode, int acceptedPackages,
int receivedPackages, int pendingPackages, String sessionStatus,
boolean assigned, boolean mergeAllowed, boolean urgent,
String mergeHint) {
String mergeHint, String groupKey, List<String> inboundCodes) {
public Receipt(String result, String containerNo, String inboundCode,
String destinationType, String palletCode, int acceptedPackages,
int receivedPackages, int pendingPackages, String sessionStatus,
boolean assigned, boolean mergeAllowed, boolean urgent, String mergeHint) {
this(result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages,
receivedPackages, pendingPackages, sessionStatus, assigned, mergeAllowed, urgent,
mergeHint, null, List.of(inboundCode));
}
public Receipt(String result, String containerNo, String inboundCode,
String destinationType, String palletCode, int acceptedPackages,
int receivedPackages, int pendingPackages, String sessionStatus) {
this(result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages,
receivedPackages, pendingPackages, sessionStatus, false, false, false, null);
receivedPackages, pendingPackages, sessionStatus, false, false, false, null,
null, List.of(inboundCode));
}
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.inboundplan.pda.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_receiving_fba_pallet_count")
public class ReceivingFbaPalletCount {
@TableId(type = IdType.INPUT)
private Long id;
private Long sessionId;
private String groupKey;
private String palletCode;
private Integer actualPackages;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.inboundplan.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ReceivingFbaPalletCountMapper extends BaseMapper<ReceivingFbaPalletCount> {
@Select("SELECT * FROM ll_receiving_fba_pallet_count WHERE session_id=#{sessionId} " +
"AND group_key=#{groupKey} AND pallet_code=#{palletCode} LIMIT 1")
ReceivingFbaPalletCount find(@Param("sessionId") long sessionId, @Param("groupKey") String groupKey,
@Param("palletCode") String palletCode);
@Select("SELECT COALESCE(SUM(actual_packages),0) FROM ll_receiving_fba_pallet_count " +
"WHERE session_id=#{sessionId} AND group_key=#{groupKey}")
int sumActual(@Param("sessionId") long sessionId, @Param("groupKey") String groupKey);
}
@@ -23,6 +23,7 @@ import org.springframework.stereotype.Repository;
import jakarta.annotation.Resource;
import java.util.List;
import java.util.Set;
@Repository
public class PdaWorkflowRepository {
@@ -91,6 +92,11 @@ public class PdaWorkflowRepository {
return pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>().eq(InboundPlanPallet::getPlanId, planId));
}
/** FBA destination groups may allocate several customers onto one logical pallet owned by another plan. */
public List<InboundPlanPallet> palletsByIds(Set<Long> palletIds) {
return palletIds == null || palletIds.isEmpty() ? List.of() : pallets.selectBatchIds(palletIds);
}
public List<InboundPlanPalletAllocation> allocations(Long planId) {
return allocations.selectList(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, planId));
@@ -45,6 +45,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Service
public class InboundPdaWorkflowService {
@@ -578,6 +579,58 @@ public class InboundPdaWorkflowService {
return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList();
}
/**
* 现场允许带差异结束:核平计划正常入库;差异计划只冻结为待核平,继续占用实物托盘和库位,
* 不生成客户可用库存。柜级卸货事实仍只发布一次。
*/
@Transactional
public void completeContainerWithDifferences(List<InboundPlan> containerPlans,
List<InboundPlan> reconciledPlans) {
if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans");
Set<Long> reconciledIds = reconciledPlans.stream().map(InboundPlan::getId).collect(java.util.stream.Collectors.toSet());
LocalDateTime now = LocalDateTime.now();
List<InboundPdaTask> allTasks = new ArrayList<>();
for (InboundPlan plan : containerPlans.stream().sorted(Comparator.comparing(InboundPlan::getId)).toList()) {
InboundPdaTask task = repository.lockTask(plan.getCustomerId(), plan.getId());
if (task == null || unloadPhotoFileIds(task).isEmpty() || task.getUnloadPhotoUploadedAt() == null) {
throw conflict("Every plan must have the signed unload evidence before onsite completion");
}
if (reconciledIds.contains(plan.getId())) {
CompletionContext context = completionContext(plan.getCustomerId(), plan);
completePlan(plan.getCustomerId(), context, now);
allTasks.add(context.task());
} else {
task.setStatus("PENDING_RECONCILIATION"); task.setCompletedAt(now); task.setUpdatedAt(now);
task.setVersion(value(task.getVersion()) + 1); repository.update(task);
plan.setStatus("PENDING_RECONCILIATION"); plan.setUpdatedAt(now); repository.update(plan);
allTasks.add(task);
}
}
String containerNo = containerPlans.get(0).getContainerNo();
if (containerPlans.stream().anyMatch(plan -> !Objects.equals(containerNo, plan.getContainerNo()))) {
throw conflict("Inbound plans must belong to one physical container");
}
String sourceContainerBookingId = sourceContainerBookingIdForPlans(containerPlans);
LocalDateTime photoUploadedAt = allTasks.stream().map(InboundPdaTask::getUnloadPhotoUploadedAt)
.max(LocalDateTime::compareTo).orElseThrow();
long aggregateVersion = allTasks.stream().map(InboundPdaTask::getVersion).filter(Objects::nonNull)
.mapToLong(Integer::longValue).max().orElse(1L);
unloadCompletedOutbox.publishContainerCompleted(sourceContainerBookingId, containerNo,
now.toInstant(ZoneOffset.UTC), photoUploadedAt.toInstant(ZoneOffset.UTC), aggregateVersion);
}
private String sourceContainerBookingIdForPlans(List<InboundPlan> plans) {
List<String> values = plans.stream().map(plan -> {
LastlegOrder order = orders.selectById(plan.getSourceOrderId());
if (order == null || order.getSourceContainerBookingId() == null || order.getSourceContainerBookingId().isBlank()) {
throw new IllegalStateException("Inbound plan has no source container booking ID");
}
return order.getSourceContainerBookingId();
}).distinct().toList();
if (values.size() != 1) throw conflict("Container plans reference different source container bookings");
return values.get(0);
}
private String sourceContainerBookingId(List<CompletionContext> contexts) {
List<String> bookingIds = contexts.stream().map(context -> {
LastlegOrder order = orders.selectById(context.plan().getSourceOrderId());
@@ -628,8 +681,15 @@ public class InboundPdaWorkflowService {
private void completePlan(String customerId, CompletionContext context, LocalDateTime now) {
InboundPlan plan = context.plan();
InboundPdaTask task = context.task();
List<InboundPlanPallet> receiptPallets = new ArrayList<>(repository.pallets(plan.getId()));
Set<Long> presentPalletIds = receiptPallets.stream().map(InboundPlanPallet::getId)
.collect(java.util.stream.Collectors.toSet());
Set<Long> sharedPalletIds = context.allocations().stream().map(InboundPlanPalletAllocation::getPalletId)
.filter(Objects::nonNull).filter(id -> !presentPalletIds.contains(id))
.collect(java.util.stream.Collectors.toSet());
receiptPallets.addAll(repository.palletsByIds(sharedPalletIds));
stockReceipts.stockIn(customerId, plan, task, context.lines(), context.allocations(),
repository.pallets(plan.getId()), now);
receiptPallets, now);
for (InboundPlanCarton carton : context.cartons()) {
carton.setStatus("ON_PALLET".equals(carton.getStatus()) ? "AVAILABLE" : "BULK_RECEIVED");
carton.setReceivedAt(now); carton.setUpdatedAt(now);
@@ -1164,7 +1224,10 @@ public class InboundPdaWorkflowService {
List<InboundPlanCarton> cartons) { }
private static int value(Integer value) { return value == null ? 0 : value; }
private static boolean terminal(String status) { return "COMPLETED".equals(status) || "PARTIAL_INBOUND".equals(status); }
private static boolean terminal(String status) {
return "COMPLETED".equals(status) || "PARTIAL_INBOUND".equals(status)
|| "PENDING_RECONCILIATION".equals(status);
}
private static BigDecimal volume(BigDecimal value) { return value == null ? ZERO_VOLUME : value; }
private static BigDecimal nonNegative(BigDecimal value) { return value.signum() < 0 ? ZERO_VOLUME : value.setScale(4, RoundingMode.HALF_UP); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
@@ -20,15 +20,20 @@ import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingFbaPalletCountMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.MapperFeature;
@@ -74,8 +79,11 @@ public class ReceivingContainerService {
@Resource private LastlegOrderDetailMapper orderDetails;
@Resource private ReceivingContainerSessionMapper sessions;
@Resource private ReceivingContainerOperationMapper operations;
@Resource private ReceivingFbaPalletCountMapper fbaPalletCounts;
@Resource private ReceivingSkuReceiptMapper skuReceipts;
@Resource private ContainerShippingGateMapper shippingGates;
@Resource private DropshipCartonMapper dropshipCartons;
@Resource private DropshipOrderProfileMapper dropshipProfiles;
@Resource private WarehouseResourceGateway warehouse;
@Resource private UnloadPhotoFileGateway unloadPhotos;
@Resource private InboundPdaWorkflowService workflow;
@@ -246,13 +254,34 @@ public class ReceivingContainerService {
public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey,
ReceivingContainerRequests.CartonResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey);
List<InboundRef> currentRefs = refs(session.getContainerNo());
String sourcePalletNo = dropshipSourcePallet(currentRefs, session.getContainerNo(), request.cartonMark());
if (sourcePalletNo != null) {
List<DropshipPalletRef> palletRefs = dropshipPalletRefs(session.getContainerNo(), sourcePalletNo);
requireCompleteManifest(session.getContainerNo(), sourcePalletNo);
List<InboundRef> group = palletRefs.stream().map(DropshipPalletRef::ref).distinct().toList();
InboundRef first = group.get(0);
List<String> inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
int total = palletRefs.stream().mapToInt(value -> value(value.allocation().getPlannedPackageCount())).sum();
int received = palletRefs.stream().mapToInt(value -> value(value.allocation().getPickedPackageCount())).sum();
ReceivingContainerResponses.Inbound inbound = inbound(first);
return new ReceivingContainerResponses.CartonResolved(request.cartonMark().trim(),
inboundCodes.get(0), inbound.destinationType(), inbound.overseasWarehouseId(),
inbound.overseasWarehouseName(), inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(),
inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), 1, total, received,
Math.max(0, total-received), true, false, urgent(first.plan()), null,
"DROPSHIP_PALLET", "DP:" + sourcePalletNo, inboundCodes, sourcePalletNo);
}
String inboundCode = cartonInboundCode(request.cartonMark());
List<InboundRef> matches = refs(session.getContainerNo()).stream()
List<InboundRef> matches = currentRefs.stream()
.filter(value -> inboundCode.equals(value.line().getInboundCode())).toList();
CartonRef carton;
if (matches.size() > 1) {
throw conflict("Carton mark inbound code is ambiguous in the current container");
} else if (matches.size() == 1 && "FBA".equals(destinationType(matches.get(0).line(), matches.get(0).order()))) {
if (request.cartonMark().trim().equals(inboundCode)) {
throw conflict("Scan one FBA carton code, not the inbound code");
}
carton = new CartonRef(matches.get(0));
} else if (matches.size() == 1) {
carton = requireCartonRef(matches.get(0).order().getCustomerId(), session.getContainerNo(), request.cartonMark());
@@ -260,6 +289,22 @@ public class ReceivingContainerService {
carton = requireUnmatchedFbaCartonPrefix(inboundCode);
}
InboundRef ref = carton.ref();
if ("FBA".equals(destinationType(ref.line(), ref.order()))
&& !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) {
List<InboundRef> group = requireFbaPlanRefs(session.getContainerNo(), ref);
List<String> inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
int total = group.stream().mapToInt(value -> value(value.line().getRequiredPackageCount())).sum();
int received = group.stream().mapToInt(this::receivedLinePackages).sum();
ReceivingContainerResponses.Inbound inbound = inbound(ref);
int palletCount = (int) group.stream().flatMap(value -> pallets.selectList(
new LambdaQueryWrapper<InboundPlanPallet>().eq(InboundPlanPallet::getPlanId, value.plan().getId())).stream())
.map(InboundPlanPallet::getWarehousePalletId).filter(Objects::nonNull).distinct().count();
return new ReceivingContainerResponses.CartonResolved(request.cartonMark().trim(), inboundCodes.get(0),
"FBA", inbound.overseasWarehouseId(), inbound.overseasWarehouseName(), inbound.destinationWarehouseId(),
inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(),
palletCount, total, received, Math.max(0,total-received), true, false, urgent(ref.plan()), null,
"FBA_GROUP", fbaGroupKey(session.getContainerNo(), ref), inboundCodes, null);
}
ReceivingContainerResponses.Inbound inbound = inbound(ref);
return new ReceivingContainerResponses.CartonResolved(request.cartonMark().trim(),
inbound.inboundCode(), inbound.destinationType(), inbound.overseasWarehouseId(),
@@ -273,10 +318,26 @@ public class ReceivingContainerService {
ReceivingContainerRequests.PalletResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey);
InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode());
PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), false);
List<InboundRef> group = "FBA".equals(destinationType(ref.line(), ref.order()))
&& !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())
? requireFbaPlanRefs(session.getContainerNo(), ref) : List.of(ref);
PalletSelection selection = group.size() == 1 && !"FBA".equals(destinationType(ref.line(), ref.order()))
? resolveReceivingPallet(ref, request.palletCode(), false) : resolveFbaGroupPallet(group, request.palletCode());
WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource();
int received = selection.pallet() == null ? 0 : group.stream().mapToInt(value -> allocations.selectList(
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, value.plan().getId())
.eq(InboundPlanPalletAllocation::getLineId, value.line().getId())
.eq(InboundPlanPalletAllocation::getPalletId, selection.pallet().getId())).stream()
.mapToInt(item -> value(item.getPickedPackageCount())).sum()).sum();
if (selection.pallet() != null && "FBA".equals(destinationType(ref.line(), ref.order()))) {
ReceivingFbaPalletCount physical = fbaPalletCounts.find(session.getId(),
fbaGroupKey(session.getContainerNo(), ref), pallet.palletNo());
if (physical != null) received = value(physical.getActualPackages());
}
return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(),
pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true);
pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true,
selection.pallet() == null ? 0 : value(selection.pallet().getVersion()), received);
}
@Transactional
@@ -286,7 +347,6 @@ public class ReceivingContainerService {
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
ReceivingContainerSession session = requireSigned(operatorKey);
InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode());
String customerId = ref.order().getCustomerId();
if ("ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) {
throw conflict("One-click dropship inbound must be operated by complete source pallet");
}
@@ -294,31 +354,70 @@ public class ReceivingContainerService {
throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden");
}
List<InboundRef> fbaPlanRefs = requireFbaPlanRefs(session.getContainerNo(), ref);
List<FbaSkuAllocation> skuAllocations = allocateFbaSkuReceipts(customerId, fbaPlanRefs, request.packageCount());
skuAllocations.forEach(allocation -> workflow.validateBulkResolved(customerId, allocation.ref().plan(),
allocation.ref().line(), allocation.packages()));
PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), true);
PalletSelection selection = resolveFbaGroupPallet(fbaPlanRefs, request.palletCode());
WarehouseResourceGateway.WarehousePalletReference resource = selection.resource();
InboundPlanPallet pallet = selection.pallet();
Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
if (request.expectedPalletVersion() != null && !Objects.equals(request.expectedPalletVersion(), pallet.getVersion())) {
throw conflict("Pallet receiving version changed; refresh the group before saving");
}
int allocatedOnPallet = fbaPlanRefs.stream().mapToInt(value -> allocations.selectList(
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, value.plan().getId())
.eq(InboundPlanPalletAllocation::getLineId, value.line().getId())
.eq(InboundPlanPalletAllocation::getPalletId, pallet.getId())).stream()
.mapToInt(item -> value(item.getPickedPackageCount())).sum()).sum();
String groupKey = fbaGroupKey(session.getContainerNo(), ref);
ReceivingFbaPalletCount physicalCount = fbaPalletCounts.find(session.getId(), groupKey, resource.palletNo());
int previous = physicalCount == null ? allocatedOnPallet : value(physicalCount.getActualPackages());
if (request.packageCount() < previous) {
throw conflict("Cumulative pallet quantity cannot be reduced after saving; use correction operation");
}
int physicalDelta = request.packageCount() - previous;
List<String> inboundCodes = fbaPlanRefs.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
if (physicalDelta == 0) {
int total = fbaPlanRefs.stream().mapToInt(value -> value(value.line().getRequiredPackageCount())).sum();
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("ALREADY_RECEIVED",
session.getContainerNo(), inboundCodes.get(0), "FBA", resource.palletNo(), 0,
fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum(),
Math.max(0,total-fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum()), session.getStatus(),
true, mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()), groupKey, inboundCodes);
save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result);
return result;
}
int groupTotal = fbaPlanRefs.stream().mapToInt(value -> value(value.line().getRequiredPackageCount())).sum();
int groupReceived = fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum();
int allocatable = Math.min(physicalDelta, Math.max(0, groupTotal - groupReceived));
List<FbaSkuAllocation> skuAllocations = allocateFbaSkuReceipts(fbaPlanRefs, allocatable);
skuAllocations.forEach(allocation -> workflow.validateBulkResolved(allocation.ref().order().getCustomerId(), allocation.ref().plan(),
allocation.ref().line(), allocation.packages()));
String palletCustomerId = pallet.getCustomerId();
InboundRef palletOwner = fbaPlanRefs.stream().filter(value -> Objects.equals(value.plan().getId(), pallet.getPlanId()))
.findFirst().orElseThrow(() -> conflict("FBA pallet does not belong to the current destination group"));
Long resourceLockId = allocatable == 0 ? null
: workflow.assignResolvedPallet(palletCustomerId, palletOwner.plan(), pallet, resource);
try {
int accepted = 0;
for (FbaSkuAllocation allocation : skuAllocations) {
accepted += workflow.receiveBulkResolved(customerId, allocation.ref().plan(), allocation.ref().line(), pallet,
String allocationCustomerId = allocation.ref().order().getCustomerId();
accepted += workflow.receiveBulkResolved(allocationCustomerId, allocation.ref().plan(), allocation.ref().line(), pallet,
allocation.packages(), operatorKey, operatorName);
recordSkuReceipt(session, allocation, pallet);
}
savePhysicalFbaCount(session.getId(), groupKey, resource.palletNo(), request.packageCount(), physicalCount);
markReceiving(session);
int received = fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum();
int total = fbaPlanRefs.stream().mapToInt(refValue -> value(refValue.line().getRequiredPackageCount())).sum();
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), ref.line().getInboundCode(), "FBA", resource.palletNo(), accepted,
boolean overReceived = fbaPalletCounts.sumActual(session.getId(), groupKey) > total;
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt(
overReceived ? "DIFFERENCE_RECORDED" : "RECEIVED",
session.getContainerNo(), inboundCodes.get(0), "FBA", resource.palletNo(), physicalDelta,
received, Math.max(0, total - received), session.getStatus(), assigned(ref.plan()),
mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()));
mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()),
groupKey, inboundCodes);
save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result);
return result;
} catch (RuntimeException failure) {
workflow.compensateReservation(customerId, resourceLockId, failure);
workflow.compensateReservation(palletCustomerId, resourceLockId, failure);
throw failure;
}
}
@@ -370,59 +469,89 @@ public class ReceivingContainerService {
ReceivingContainerOperation replay = replay(operatorKey, "PALLET_RECEIVE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
ReceivingContainerSession session = requireSigned(operatorKey);
List<DropshipPalletRef> matches = new ArrayList<>();
for (InboundRef ref : refs(session.getContainerNo())) {
if (!"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue;
String customerId = ref.order().getCustomerId();
List<InboundPlanPallet> sourcePallets = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, customerId)
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())
.eq(InboundPlanPallet::getSourcePalletNo, request.sourcePalletNo()));
for (InboundPlanPallet pallet : sourcePallets) {
List<InboundPlanPalletAllocation> palletAllocations = allocations.selectList(
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getCustomerId, customerId)
.eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId())
.eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())
.eq(InboundPlanPalletAllocation::getPalletId, pallet.getId()));
palletAllocations.forEach(allocation -> matches.add(new DropshipPalletRef(ref, pallet, allocation)));
String sourcePalletNo = dropshipSourcePallet(session.getContainerNo(), request.sourcePalletNo());
if (sourcePalletNo == null) throw notFound("DP or carton is not part of the current container");
requireCompleteManifest(session.getContainerNo(), sourcePalletNo);
List<DropshipPalletRef> matches = dropshipPalletRefs(session.getContainerNo(), sourcePalletNo);
boolean alreadyReceived = matches.stream().allMatch(match ->
value(match.allocation().getPickedPackageCount()) == value(match.allocation().getPlannedPackageCount()));
int accepted = 0;
if (!alreadyReceived) {
if (matches.stream().anyMatch(match -> value(match.allocation().getPickedPackageCount()) != 0)) {
throw conflict("DP pallet is only partially received; reconcile it before retrying");
}
for (DropshipPalletRef match : matches) {
int packages = value(match.allocation().getPlannedPackageCount());
workflow.pickWholePallet(match.ref().order().getCustomerId(), match.ref().plan().getPlanNo(),
match.allocation().getId(), new PdaRequests.WholePalletOperation(
request.idempotencyKey() + ":PICK:" + match.allocation().getId(), operatorKey, operatorName));
accepted += packages;
match.allocation().setPickedPackageCount(packages);
}
}
if (matches.isEmpty()) throw notFound("Source pallet is not part of the current container");
if (matches.size() != 1) throw conflict("Source pallet is duplicated inside the current container");
DropshipPalletRef match = matches.get(0);
String customerId = match.ref().order().getCustomerId();
int accepted = value(match.allocation().getPlannedPackageCount());
workflow.pickWholePallet(customerId, match.ref().plan().getPlanNo(), match.allocation().getId(),
new PdaRequests.WholePalletOperation(request.idempotencyKey() + ":PICK", operatorKey, operatorName));
markReceiving(session);
ReceivingContainerResponses.Inbound inbound = inbound(match.ref());
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), match.pallet().getPalletNo(),
accepted, inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus(),
inbound.assigned(), inbound.mergeAllowed(), inbound.urgent(), inbound.mergeHint());
List<InboundRef> group = matches.stream().map(DropshipPalletRef::ref).distinct().toList();
List<String> inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
int total = matches.stream().mapToInt(value -> value(value.allocation().getPlannedPackageCount())).sum();
int received = matches.stream().mapToInt(value -> value(value.allocation().getPickedPackageCount())).sum();
InboundRef first = group.get(0);
ReceivingContainerResponses.Inbound inbound = inbound(first);
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt(
alreadyReceived ? "ALREADY_RECEIVED" : "RECEIVED", session.getContainerNo(), inboundCodes.get(0),
inbound.destinationType(), sourcePalletNo, accepted, received, Math.max(0,total-received), session.getStatus(),
true, false, urgent(first.plan()), null, "DP:" + sourcePalletNo, inboundCodes);
save(session, "PALLET_RECEIVE", request.idempotencyKey(), request, result);
return result;
}
@Transactional(isolation = Isolation.READ_COMMITTED)
public ReceivingContainerResponses.Session complete(String operatorKey,
ReceivingContainerRequests.Action request) {
ReceivingContainerRequests.Complete request) {
ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
ReceivingContainerSession session = requireSigned(operatorKey);
List<InboundPlan> containerPlans = requirePlans(session.getContainerNo());
Totals totals = totals(containerPlans);
if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container");
validateFbaSkuBalance(session.getContainerNo());
workflow.completeContainer(containerPlans);
boolean hasDifference = totals.total() != totals.received() || hasFbaOverReceipt(session, containerPlans);
if (hasDifference && !request.allowDifference()) {
throw conflict("Container has a receiving difference; confirm onsite completion with difference");
}
if (!hasDifference) {
validateFbaSkuBalance(session.getContainerNo());
workflow.completeContainer(containerPlans);
} else {
workflow.completeContainerWithDifferences(containerPlans, reconciledPlans(containerPlans));
}
LocalDateTime now = LocalDateTime.now();
session.setStatus("COMPLETED"); session.setCompletedAt(now); session.setUpdatedAt(now);
session.setStatus(hasDifference ? "COMPLETED_WITH_DIFFERENCE" : "COMPLETED");
session.setCompletedAt(now); session.setUpdatedAt(now);
session.setActiveOperatorKey(null); session.setActiveContainerKey(null); update(session);
ReceivingContainerResponses.Session result = snapshot(session, containerPlans);
save(session, "COMPLETE", request.idempotencyKey(), request, result); return result;
}
private List<InboundPlan> reconciledPlans(List<InboundPlan> containerPlans) {
Set<Long> complete = refs(containerPlans.get(0).getContainerNo()).stream()
.collect(Collectors.groupingBy(value -> value.plan().getId(), LinkedHashMap::new, Collectors.toList()))
.entrySet().stream().filter(entry -> entry.getValue().stream().allMatch(ref ->
receivedLinePackages(ref) == value(ref.line().getRequiredPackageCount())))
.map(Map.Entry::getKey).collect(Collectors.toSet());
Set<String> incompleteFbaGroups = refs(containerPlans.get(0).getContainerNo()).stream()
.filter(ref -> !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType()))
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
.filter(ref -> !complete.contains(ref.plan().getId()))
.map(ref -> normalizedFba(ref.line().getFbaWarehouseCode())).collect(Collectors.toSet());
refs(containerPlans.get(0).getContainerNo()).stream()
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
.filter(ref -> fbaGroupHasOverReceipt(findSession(containerPlans.get(0).getContainerNo()), ref))
.map(ref -> normalizedFba(ref.line().getFbaWarehouseCode())).forEach(incompleteFbaGroups::add);
return containerPlans.stream().filter(plan -> complete.contains(plan.getId()))
.filter(plan -> refs(plan.getContainerNo()).stream().filter(ref -> Objects.equals(ref.plan().getId(), plan.getId()))
.noneMatch(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))
&& incompleteFbaGroups.contains(normalizedFba(ref.line().getFbaWarehouseCode()))))
.toList();
}
private ReceivingContainerSession requireCurrent(String operatorKey, boolean lock) {
String key = activeOperatorKey(operatorKey);
ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(key)
@@ -503,7 +632,8 @@ public class ReceivingContainerService {
List<InboundPlan> containerPlans = planFacts.stream()
.filter(plan -> containerNo.equals(plan.getContainerNo())).toList();
boolean allPlansCompleted = !containerPlans.isEmpty()
&& containerPlans.stream().allMatch(plan -> "COMPLETED".equals(plan.getStatus()));
&& containerPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION")
.contains(plan.getStatus()));
boolean shippingReady = shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1;
boolean operable = allPlansReady && shippingReady && warehouseSnapshotConsistent;
int pending = Math.max(0, totalOrders - plannedOrders);
@@ -565,7 +695,8 @@ public class ReceivingContainerService {
if (values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) {
throw conflict("Every inbound plan in the receiving container must have an owning customer");
}
if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) {
if (values.stream().allMatch(value -> List.of("COMPLETED", "PENDING_RECONCILIATION")
.contains(value.getStatus()))) {
throw conflict("Receiving container is already completed");
}
return values;
@@ -634,16 +765,20 @@ public class ReceivingContainerService {
private List<InboundRef> requireFbaPlanRefs(String containerNo, InboundRef selected) {
List<InboundRef> values = refs(containerNo).stream()
.filter(value -> Objects.equals(value.plan().getId(), selected.plan().getId()))
.sorted(Comparator.comparing(value -> value.line().getId())).toList();
.filter(value -> !"ONE_CLICK_DROPSHIP".equals(value.order().getOrderType()))
.filter(value -> "FBA".equals(destinationType(value.line(), value.order())))
.filter(value -> Objects.equals(normalizedFba(value.line().getFbaWarehouseCode()),
normalizedFba(selected.line().getFbaWarehouseCode())))
.sorted(Comparator.comparing((InboundRef value) -> value.line().getInboundCode())
.thenComparing(value -> value.line().getId())).toList();
if (values.isEmpty() || values.stream().anyMatch(value ->
!"FBA".equals(destinationType(value.line(), value.order())))) {
throw conflict("FBA bulk receiving requires every line in the selected inbound plan to be FBA");
throw conflict("FBA receiving group is invalid");
}
Set<String> destinations = values.stream().map(value -> trim(value.line().getFbaWarehouseCode()))
.collect(Collectors.toSet());
if (destinations.size() != 1 || destinations.contains(null)) {
throw conflict("FBA bulk receiving requires one destination warehouse per inbound plan");
throw conflict("FBA receiving group requires one destination warehouse");
}
return values;
}
@@ -694,6 +829,29 @@ public class ReceivingContainerService {
return new PalletSelection(pallet, resource);
}
private PalletSelection resolveFbaGroupPallet(List<InboundRef> refs, String palletCode) {
String normalized = palletCode.trim();
List<InboundPlanPallet> matches = refs.stream().flatMap(ref -> pallets.selectList(
new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream())
.filter(value -> matchesPlannedPalletCode(value, normalized)).filter(this::hasConfirmedReservation)
.collect(Collectors.toMap(InboundPlanPallet::getId, value -> value, (left,right)->left,
LinkedHashMap::new)).values().stream().toList();
if (matches.isEmpty()) throw notFound("Pallet code is not part of the current FBA destination group");
if (matches.size() != 1) throw conflict("Pallet code is duplicated in the current FBA destination group");
InboundPlanPallet pallet = matches.get(0);
InboundRef owner = refs.stream().filter(ref -> Objects.equals(ref.plan().getId(), pallet.getPlanId()))
.findFirst().orElseThrow(() -> conflict("Pallet owner plan is not part of the FBA destination group"));
long warehouseId = owner.plan().getOverseasWarehouseId() == null
? Long.parseLong(owner.order().getOverseasWarehouseId()) : owner.plan().getOverseasWarehouseId();
WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolveReservedPallet(
pallet.getPalletNo(), owner.order().getCustomerId(), warehouseId, pallet.getWarehousePalletId(),
pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId());
validatePallet(owner, resource);
return new PalletSelection(pallet, resource);
}
private boolean matchesPlannedPalletCode(InboundPlanPallet pallet, String scannedCode) {
return scannedCode.equals(trim(pallet.getPalletNo()))
|| scannedCode.equals(trim(pallet.getSourcePalletNo()));
@@ -750,6 +908,68 @@ public class ReceivingContainerService {
return Objects.equals(line.getDestinationWarehouseCode(), pallet.getDestinationWarehouseCode());
}
private String dropshipSourcePallet(String containerNo, String scanCode) {
return dropshipSourcePallet(refs(containerNo), containerNo, scanCode);
}
private String dropshipSourcePallet(List<InboundRef> currentRefs, String containerNo, String scanCode) {
String normalized = trim(scanCode);
if (normalized == null) return null;
List<InboundRef> dropshipRefs = currentRefs.stream()
.filter(ref -> "ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())).toList();
if (dropshipRefs.isEmpty()) return null;
boolean direct = normalized.toUpperCase(java.util.Locale.ROOT).startsWith("DP") && dropshipRefs.stream()
.anyMatch(ref -> pallets.selectCount(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())
.eq(InboundPlanPallet::getSourcePalletNo, normalized)) > 0);
if (direct) return normalized;
List<String> resolved = dropshipCartons.sourcePalletsInContainer(containerNo, normalized);
if (resolved.size() > 1) throw conflict("Carton belongs to more than one DP in the current container");
return resolved.isEmpty() ? null : resolved.get(0);
}
private List<DropshipPalletRef> dropshipPalletRefs(String containerNo, String sourcePalletNo) {
List<DropshipPalletRef> result = new ArrayList<>();
for (InboundRef ref : refs(containerNo)) {
if (!"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue;
List<InboundPlanPallet> sourcePallets = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())
.eq(InboundPlanPallet::getSourcePalletNo, sourcePalletNo));
for (InboundPlanPallet pallet : sourcePallets) {
allocations.selectList(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getCustomerId, ref.order().getCustomerId())
.eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId())
.eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())
.eq(InboundPlanPalletAllocation::getPalletId, pallet.getId()))
.forEach(allocation -> result.add(new DropshipPalletRef(ref, pallet, allocation)));
}
}
if (result.isEmpty()) throw notFound("DP is not part of the current container");
Set<Long> physicalPallets = result.stream().map(value -> value.pallet().getWarehousePalletId())
.filter(Objects::nonNull).collect(Collectors.toSet());
if (physicalPallets.size() != 1) throw conflict("DP customer fragments do not share one physical pallet");
return result;
}
private void requireCompleteManifest(String containerNo, String sourcePalletNo) {
List<DropshipOrderProfile> profiles = dropshipProfiles.listContainerPallet(containerNo, sourcePalletNo);
if (profiles.isEmpty()) throw notFound("DP manifest was not found in the current container");
Set<Integer> versions = profiles.stream().map(DropshipOrderProfile::getManifestVersion).collect(Collectors.toSet());
Set<Integer> totals = profiles.stream().map(DropshipOrderProfile::getManifestPackageCount).collect(Collectors.toSet());
Set<Integer> fragments = profiles.stream().map(DropshipOrderProfile::getManifestFragmentCount).collect(Collectors.toSet());
Set<String> hashes = profiles.stream().map(DropshipOrderProfile::getManifestHash).collect(Collectors.toSet());
if (versions.size()!=1 || totals.size()!=1 || fragments.size()!=1 || hashes.size()!=1) {
throw conflict("DP manifest fragments are inconsistent");
}
long customerFragments = profiles.stream().map(DropshipOrderProfile::getCustomerId).distinct().count();
long cartons = profiles.stream().mapToLong(profile ->
dropshipCartons.listProfile(profile.getCustomerId(), profile.getId()).size()).sum();
if (customerFragments != fragments.iterator().next() || cartons != totals.iterator().next()) {
throw conflict("DP manifest fragments are incomplete; wait for all customer fragments");
}
}
private record PalletSelection(InboundPlanPallet pallet,
WarehouseResourceGateway.WarehousePalletReference resource) { }
@@ -764,7 +984,40 @@ public class ReceivingContainerService {
return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(),
session.getOverseasWarehouseCode(), session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(),
value(session.getVersion()), signPhotos(session), session.getPreparedAt(), session.getSignedAt(),
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds);
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received),
hasFbaOverReceipt(session, containerPlans), inbounds);
}
private void savePhysicalFbaCount(long sessionId, String groupKey, String palletCode, int actualPackages,
ReceivingFbaPalletCount existing) {
LocalDateTime now = LocalDateTime.now();
if (existing == null) {
ReceivingFbaPalletCount value = new ReceivingFbaPalletCount();
value.setId(IdWorker.getId()); value.setSessionId(sessionId); value.setGroupKey(groupKey);
value.setPalletCode(palletCode); value.setActualPackages(actualPackages);
value.setCreatedAt(now); value.setUpdatedAt(now); fbaPalletCounts.insert(value);
} else {
existing.setActualPackages(actualPackages); existing.setUpdatedAt(now); fbaPalletCounts.updateById(existing);
}
}
private boolean hasFbaOverReceipt(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
if (session == null || containerPlans == null || containerPlans.isEmpty()) return false;
return refs(session.getContainerNo()).stream()
.filter(ref -> !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType()))
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
.anyMatch(ref -> fbaGroupHasOverReceipt(session, ref));
}
private boolean fbaGroupHasOverReceipt(ReceivingContainerSession session, InboundRef ref) {
if (session == null || session.getId() == null) return false;
int planned = requireFbaPlanRefs(session.getContainerNo(), ref).stream()
.mapToInt(value -> value(value.line().getRequiredPackageCount())).sum();
return fbaPalletCounts.sumActual(session.getId(), fbaGroupKey(session.getContainerNo(), ref)) > planned;
}
private ReceivingContainerSession findSession(String containerNo) {
return sessions.findActiveByContainer(activeContainerKey(containerNo));
}
private ReceivingContainerResponses.Inbound inbound(InboundRef ref) {
@@ -844,10 +1097,11 @@ public class ReceivingContainerService {
throw conflict("Unload photo file ID must be a positive decimal string");
}
private List<FbaSkuAllocation> allocateFbaSkuReceipts(String customerId, List<InboundRef> refs, int packageCount) {
private List<FbaSkuAllocation> allocateFbaSkuReceipts(List<InboundRef> refs, int packageCount) {
int remaining = packageCount;
List<FbaSkuAllocation> result = new ArrayList<>();
for (InboundRef ref : refs) {
String customerId = ref.order().getCustomerId();
int linePending = value(ref.line().getRequiredPackageCount()) - receivedLinePackages(ref);
List<SkuExpectation> expectations = skuExpectations(ref);
int skuPending = expectations.stream().mapToInt(expectation -> expectation.expectedPackages()
@@ -958,6 +1212,15 @@ public class ReceivingContainerService {
return value;
}
private String fbaGroupKey(String containerNo, InboundRef ref) {
return "FBA:" + containerNo + ":" + normalizedFba(ref.line().getFbaWarehouseCode());
}
private String normalizedFba(String value) {
String normalized = trim(value);
return normalized == null ? null : normalized.toUpperCase(java.util.Locale.ROOT);
}
private void markReceiving(ReceivingContainerSession session) {
if (!"RECEIVING".equals(session.getStatus())) {
session.setStatus("RECEIVING"); session.setReceivingAt(LocalDateTime.now());
@@ -253,7 +253,7 @@ public class ContainerInboundPlanConversionCoordinator {
throw new ResponseStatusException(HttpStatus.CONFLICT, "计划托数不能少于已生成的头程托盘码数量");
}
String fbaCode = trimToNull(order.fbaWarehouseCode());
if (fbaCode != null) {
if (fbaCode != null && !"ONE_CLICK_DROPSHIP".equals(order.orderType())) {
for (String palletCode : sourcePalletNos) {
PalletCodeParts parts = palletCodeParts(preview.containerNo(), palletCode);
String previousPrefix = prefixByFba.putIfAbsent(fbaCode, parts.prefix());
@@ -329,10 +329,7 @@ public class ContainerInboundPlanConversionCoordinator {
throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次包含重复业务键");
}
}
if (response.items().stream().map(WarehouseResourceGateway.PalletBatchItem::palletId).distinct().count() != response.items().size()
|| response.items().stream().map(WarehouseResourceGateway.PalletBatchItem::resourceLockId).distinct().count() != response.items().size()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次包含重复托盘或资源锁");
}
validateSharedPallets(preparation.items(), byKey);
return preparation.items().stream().collect(Collectors.groupingBy(PreparedItem::orderId)).values().stream()
.map(items -> {
PreparedItem first = items.get(0);
@@ -356,6 +353,22 @@ public class ContainerInboundPlanConversionCoordinator {
return value;
}
private void validateSharedPallets(List<PreparedItem> expected,
Map<String, WarehouseResourceGateway.PalletBatchItem> actual) {
Map<Long, List<PreparedItem>> byPallet = expected.stream().collect(Collectors.groupingBy(item ->
requiredReservation(actual.get(item.businessKey()), item).palletId()));
for (List<PreparedItem> values : byPallet.values()) {
if (values.size() == 1) continue;
Set<String> codes = values.stream().map(PreparedItem::palletCode).collect(Collectors.toSet());
Set<Long> locks = values.stream().map(item -> requiredReservation(actual.get(item.businessKey()), item).resourceLockId())
.collect(Collectors.toSet());
if (codes.size() != 1 || codes.iterator().next() == null
|| !codes.iterator().next().toUpperCase(java.util.Locale.ROOT).startsWith("DP") || locks.size() != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "只有同一码的一件代发 DP 才能共享实物托盘");
}
}
}
private void requireBatch(WarehouseResourceGateway.PalletBatchResponse response, String batchKey, int size,
String... acceptedStatuses) {
if (response == null || response.batchId() <= 0 || response.attemptNo() <= 0
@@ -207,6 +207,10 @@ public class InboundPlanService {
items.stream().filter(InboundPlanService::requiresContainerConversion)
.map(InboundPlanService::pendingPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(),
items.stream().filter(InboundPlanService::requiresContainerConversion)
.mapToInt(ContainerInboundPlanPreviewResponse.Order::sourcePalletCount).sum(),
items.stream().filter(InboundPlanService::requiresContainerConversion)
.mapToInt(ContainerInboundPlanPreviewResponse.Order::supplementPalletCount).sum(),
items.stream().filter(item -> "PLANNED".equals(item.readiness()))
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(),
@@ -220,6 +224,10 @@ public class InboundPlanService {
int pendingPallets = orders.stream().filter(InboundPlanService::requiresContainerConversion)
.map(InboundPlanService::pendingPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
int sourcePallets = orders.stream().filter(InboundPlanService::requiresContainerConversion)
.mapToInt(ContainerInboundPlanPreviewResponse.Order::sourcePalletCount).sum();
int supplementPallets = orders.stream().filter(InboundPlanService::requiresContainerConversion)
.mapToInt(ContainerInboundPlanPreviewResponse.Order::supplementPalletCount).sum();
int existingPallets = orders.stream().filter(item -> "PLANNED".equals(item.readiness()))
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
@@ -228,7 +236,7 @@ public class InboundPlanService {
bookingId, containerNo, warehouseId, snapshotVersion, orders));
return new ContainerInboundPlanPreviewResponse(bookingId, containerNo, warehouseId, warehouseCode,
warehouseName, snapshotVersion, snapshotHash, orders.size(), orders.size() - blocked, blocked,
pendingOrders, pendingPallets, existingPallets,
pendingOrders, pendingPallets, sourcePallets, supplementPallets, existingPallets,
orders.stream().map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(),
orders.stream().map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
@@ -258,6 +266,12 @@ public class InboundPlanService {
|| row.packageCount() > 0 && row.volumeCbm().signum() == 0) blockers.add("CARGO_FACT_INVALID");
}
Integer suggested = suggestedPalletCount(row, blockers);
int sourcePalletCount = "ONE_CLICK_DROPSHIP".equals(row.orderType())
? Objects.requireNonNullElse(suggested, 0)
: Objects.requireNonNullElse(row.sourcePalletCount(), 0);
int targetPalletCount = Objects.requireNonNullElse(row.plannedPalletCount(),
Objects.requireNonNullElse(suggested, 0));
int supplementPalletCount = Math.max(0, targetPalletCount - sourcePalletCount);
if (!planned && (suggested == null || suggested <= 0)) blockers.add("PALLET_COUNT_NOT_READY");
String destinationType = destinationType(row);
String fbaWarehouseCode = isFbaDestination(row) ? trimToNull(row.fbaWarehouseCode()) : null;
@@ -281,7 +295,8 @@ public class InboundPlanService {
String.valueOf(row.orderId()), trimToNull(row.orderNo()) == null ? row.sourceOrderNo() : row.orderNo(),
row.inboundCode(), row.orderType(), destinationType, row.destinationWarehouseId(),
destinationCode, row.destinationWarehouseName(), fbaWarehouseCode, row.packageCount(), row.volumeCbm(),
suggested, row.plannedPalletCount(), row.planNo(), row.planStatus(), row.version(), readiness,
suggested, sourcePalletCount, targetPalletCount, supplementPalletCount,
row.plannedPalletCount(), row.planNo(), row.planStatus(), row.version(), readiness,
List.copyOf(blockers));
}
@@ -321,9 +336,7 @@ public class InboundPlanService {
}
private static Integer pendingPalletCount(ContainerInboundPlanPreviewResponse.Order order) {
return "RESOURCE_PENDING".equals(order.readiness())
? Objects.requireNonNullElse(order.plannedPalletCount(), 0)
: Objects.requireNonNullElse(order.suggestedPalletCount(), 0);
return order.targetPalletCount();
}
private static String destinationType(InboundPlanOrderMapper.ContainerPreviewOrderRow row) {
@@ -842,7 +855,9 @@ public class InboundPlanService {
InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE"));
if (plan == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
if ("COMPLETED".equals(plan.getStatus())) throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed plan cannot add pallets");
if (List.of("COMPLETED", "PENDING_RECONCILIATION").contains(plan.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed plan cannot add pallets");
}
List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getPlanId, plan.getId()));
boolean destinationExists = lines.stream().anyMatch(line -> request.destinationWarehouseId().equals(line.getDestinationWarehouseId()));
@@ -998,6 +1013,11 @@ public class InboundPlanService {
}
WarehouseResourceGateway.WarehouseReference authoritativeWarehouse = resolvedWarehouse == null
? requiredReceivingWarehouse(receivingWarehouse.overseasWarehouseId()) : resolvedWarehouse;
InboundPlanOrderMapper.DetailOrderSnapshot sourceOrder = plan.getSourceOrderId() == null ? null
: orderMapper.findDetailOrder(plan.getCustomerId(), plan.getSourceOrderId());
if (plan.getSourceOrderId() != null && sourceOrder == null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "入库计划关联订单不存在");
}
List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getPlanId, plan.getId()).orderByAsc(InboundPlanLine::getId));
List<InboundPlanPallet> pallets = palletMapper.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
@@ -1006,8 +1026,39 @@ public class InboundPlanService {
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, plan.getId())
.orderByAsc(InboundPlanPalletAllocation::getId));
List<Long> referencedPalletIds = allocations.stream()
.map(InboundPlanPalletAllocation::getPalletId)
.filter(Objects::nonNull)
.distinct()
.toList();
Map<Long, InboundPlanPallet> referencedPallets = referencedPalletIds.isEmpty()
? Map.of()
: palletMapper.selectBatchIds(referencedPalletIds).stream()
.collect(Collectors.toMap(InboundPlanPallet::getId, pallet -> pallet));
if (referencedPallets.size() != referencedPalletIds.size()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "入库计划分配引用的托盘不存在");
}
List<Long> externalOwnerPlanIds = referencedPallets.values().stream()
.map(InboundPlanPallet::getPlanId)
.filter(Objects::nonNull)
.filter(ownerPlanId -> !Objects.equals(ownerPlanId, plan.getId()))
.distinct()
.toList();
Map<Long, String> palletOwnerPlanNos = new HashMap<>();
palletOwnerPlanNos.put(plan.getId(), plan.getPlanNo());
if (!externalOwnerPlanIds.isEmpty()) {
palletOwnerPlanNos.putAll(planMapper.selectBatchIds(externalOwnerPlanIds).stream()
.collect(Collectors.toMap(InboundPlan::getId, InboundPlan::getPlanNo)));
}
if (referencedPallets.values().stream().anyMatch(pallet ->
pallet.getPlanId() == null || !palletOwnerPlanNos.containsKey(pallet.getPlanId()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "入库计划分配引用的托盘所属计划不存在");
}
return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getSourceOrderId(),
plan.getContainerNo(), authoritativeWarehouse.warehouseCode(), authoritativeWarehouse.warehouseName(),
sourceOrder == null ? null : sourceOrder.orderNo(),
sourceOrder == null ? null : sourceOrder.customerName(),
sourceOrder == null ? null : sourceOrder.orderType(), plan.getContainerNo(),
authoritativeWarehouse.warehouseCode(), authoritativeWarehouse.warehouseName(),
plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), plan.getTotalVolumeCbm(),
lines.stream().mapToInt(InboundPlanLine::getRequiredPackageCount).sum(),
allocations.stream().mapToInt(InboundPlanPalletAllocation::getPickedPackageCount).sum(),
@@ -1020,12 +1071,18 @@ public class InboundPlanService {
pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId(),
pallet.getPalletType(), pallet.getAssignmentStatus(), pallet.getLockedAt(), pallet.getActualVolumeCbm(),
pallet.getActualPackageCount())).toList(),
allocations.stream().map(item -> new InboundPlanResponse.Allocation(item.getLineId(), item.getPalletId(),
allocations.stream().map(item -> {
InboundPlanPallet referencedPallet = referencedPallets.get(item.getPalletId());
return new InboundPlanResponse.Allocation(item.getLineId(), item.getPalletId(),
referencedPallet.getPalletNo(), palletOwnerPlanNos.get(referencedPallet.getPlanId()),
Objects.equals(referencedPallet.getPlanId(), plan.getId()),
item.getAllocatedVolumeCbm(), item.getPlannedPackageCount(), item.getCountedPackageCount(),
item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion())).toList(),
item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion());
}).toList(),
plan.getVersion() == null ? 0 : plan.getVersion(), plan.getPlannedArrivalDate(), plan.getPalletType(),
Boolean.TRUE.equals(plan.getAssignedFlag()), Boolean.TRUE.equals(plan.getMergeRequired()),
Boolean.TRUE.equals(plan.getUrgentFlag()), plan.getCreationMode(), plan.getPlanRemark());
Boolean.TRUE.equals(plan.getUrgentFlag()), plan.getCreationMode(), plan.getPlanRemark(),
plan.getCreatedAt(), plan.getUpdatedAt());
}
private WarehouseResourceGateway.WarehouseReference requiredReceivingWarehouse(String warehouseId) {
@@ -23,8 +23,8 @@ public interface InventoryCommandMapper {
@Param("balanceId") Long balanceId);
@Select("SELECT COALESCE(SUM(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages),0) " +
"FROM ll_inventory_position WHERE customer_id=#{customerId} AND pallet_id=#{palletId}")
long palletPackages(@Param("customerId") String customerId, @Param("palletId") Long palletId);
"FROM ll_inventory_position WHERE pallet_id=#{palletId}")
long palletPackages(@Param("palletId") Long palletId);
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " +
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} " +
@@ -239,7 +239,7 @@ public class InventoryCommandService {
balance.setPickedPackages(value(balance.getPickedPackages()) - request.packages());
touch(balance);
balances.updateById(balance);
boolean palletEmpty = commands.palletPackages(customerId, position.getPalletId()) == 0;
boolean palletEmpty = commands.palletPackages(position.getPalletId()) == 0;
if (palletEmpty) warehouseGateway.scrapPallet(customerId, position.getPalletId());
ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), "SHIP",
"PICKED", "SHIPPED", before, -request.packages(), before - request.packages(),
@@ -417,6 +417,9 @@ public class OverseasProcurementService implements WalletResultHandler {
} catch (NumberFormatException exception) {
throw badRequest("purchaseWarehouseId must be a numeric overseas partner warehouse ID");
}
// Keep the warehouse snapshot already frozen on a historical order. A later master-data
// deactivation must not block unrelated edits such as recording outbound details.
if (Long.toString(numericId).equals(order.getPurchaseWarehouseId())) return;
var option = warehouseDirectory.required(numericId);
order.setPurchaseWarehouseId(option.warehouseId());
order.setPurchaseWarehouseName(option.warehouseName());
@@ -267,7 +267,7 @@ public class RelabelApplicationService implements WalletResultHandler {
if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required");
boolean requiresOutbound = request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item)));
String deliveryMethod = trim(request.deliveryMethod());
if (requiresOutbound && !DELIVERY_METHODS.contains(deliveryMethod)) {
if (requiresOutbound && (!StringUtils.hasText(deliveryMethod) || !DELIVERY_METHODS.contains(deliveryMethod))) {
throw badRequest("Delivery method must be TRUCK, PICKUP or EXPRESS when carton labels are changed");
}
if (!requiresOutbound && StringUtils.hasText(deliveryMethod)) {
@@ -37,6 +37,13 @@ public class MerchantReturnInventoryController {
return ApiResponse.success(service.summaryPage(customer(user), request));
}
@GetMapping("/cartons")
@PreAuthorize("hasAuthority('" + LIST_PERMISSION + "')")
public ApiResponse<PageResult<ReturnInventoryResponses.CartonSummary>> cartons(@AuthenticationPrincipal AuthenticatedUser user,
@Valid ReturnInventoryRequests.Query request) {
return ApiResponse.success(service.cartonPage(customer(user), request));
}
@GetMapping("/cartons/{cartonMark}")
@PreAuthorize("hasAuthority('" + LIST_PERMISSION + "')")
public ApiResponse<ReturnInventoryResponses.CartonDetail> carton(@AuthenticationPrincipal AuthenticatedUser user,
@@ -7,6 +7,11 @@ import java.util.List;
public final class ReturnInventoryResponses {
private ReturnInventoryResponses() { }
public record CartonSummary(String cartonMark, String overseasWarehouseId, String sourceBatchNo,
String sourceReturnNo, String status, int skuTypes, long totalQuantity,
BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight,
LocalDateTime createdAt) { }
public record InventoryItem(String overseasWarehouseId, String cartonMark, String sourceBatchNo, String status, String skuCode,
String productNameZh, long quantity, int cartonSkuTypes, long cartonTotalQuantity,
LocalDateTime receivedAt) { }
@@ -6,6 +6,7 @@ import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@@ -20,6 +21,12 @@ public interface ReturnInventoryBalanceMapper extends BaseMapper<ReturnInventory
@Param("totalQuantity") long totalQuantity, @Param("availableQuantity") long availableQuantity,
@Param("cartons") long cartons);
@Update("UPDATE ll_return_inventory_balance SET carton_quantity=carton_quantity+#{cartons},updated_at=NOW(3) " +
"WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND sku_code=#{skuCode} " +
"AND carton_quantity+#{cartons}>=0")
int adjustCartonQuantity(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode, @Param("cartons") long cartons);
@Select("<script>SELECT COUNT(*) FROM ll_return_inventory_balance WHERE customer_id=#{customerId} " +
"<if test='warehouseId != null and warehouseId != \"\"'>AND overseas_warehouse_id=#{warehouseId} </if>" +
"<if test='skuCode != null and skuCode != \"\"'>AND sku_code LIKE CONCAT('%',#{skuCode},'%') </if></script>")
@@ -9,6 +9,21 @@ import java.util.List;
@Mapper
public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryCarton> {
String MERCHANT_FILTER = " FROM ll_return_inventory_carton c WHERE c.customer_id=#{customerId} " +
"<if test='query.overseasWarehouseId != null and query.overseasWarehouseId != \"\"'>AND c.overseas_warehouse_id=#{query.overseasWarehouseId} </if>" +
"<if test='query.cartonMark != null and query.cartonMark != \"\"'>AND c.carton_mark LIKE CONCAT('%',#{query.cartonMark},'%') </if>" +
"<if test='query.returnNo != null and query.returnNo != \"\"'>AND c.source_return_no LIKE CONCAT('%',#{query.returnNo},'%') </if>" +
"<if test='query.status != null and query.status != \"\"'>AND c.status=#{query.status} </if>" +
"<if test='query.skuCode != null and query.skuCode != \"\"'>AND EXISTS(SELECT 1 FROM ll_return_inventory_carton_item i WHERE i.carton_id=c.id AND i.sku_code LIKE CONCAT('%',#{query.skuCode},'%')) </if>";
@Select("<script>SELECT COUNT(*)" + MERCHANT_FILTER + "</script>")
long merchantCount(@Param("customerId") String customerId,
@Param("query") com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests.Query query);
@Select("<script>SELECT c.*" + MERCHANT_FILTER + "ORDER BY c.created_at DESC,c.id DESC LIMIT #{offset},#{query.size}</script>")
List<ReturnInventoryCarton> merchantPage(@Param("customerId") String customerId,
@Param("query") com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests.Query query, @Param("offset") long offset);
@Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND status='AVAILABLE' ORDER BY created_at DESC,id DESC LIMIT 500")
List<ReturnInventoryCarton> findAvailable(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId);
@Select("SELECT * FROM ll_return_inventory_carton WHERE carton_mark=#{cartonMark} LIMIT 1")
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.returnreceipt.service;
import com.google.zxing.oned.Code128Writer;
/** Inline vector bars remain printable even when browser background printing is disabled. */
final class ReturnCartonBarcode {
private static final int QUIET_ZONE = 10;
private ReturnCartonBarcode() { }
static String svg(String cartonMark) {
boolean[] modules = new Code128Writer().encode(cartonMark);
int width = modules.length + QUIET_ZONE * 2;
StringBuilder svg = new StringBuilder("<svg xmlns=\"http://www.w3.org/2000/svg\" role=\"img\" aria-label=\"箱号条码\" viewBox=\"0 0 ")
.append(width).append(" 50\" preserveAspectRatio=\"none\" style=\"display:block;width:100%;height:15mm\">")
.append("<rect width=\"100%\" height=\"100%\" fill=\"white\"/>");
for (int index = 0; index < modules.length;) {
if (!modules[index]) { index++; continue; }
int start = index;
while (index < modules.length && modules[index]) index++;
svg.append("<rect x=\"").append(start + QUIET_ZONE).append("\" y=\"0\" width=\"")
.append(index - start).append("\" height=\"50\" fill=\"black\"/>");
}
return svg.append("</svg>").toString();
}
}
@@ -97,6 +97,19 @@ public class ReturnInventoryService {
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@Transactional(readOnly = true)
public PageResult<ReturnInventoryResponses.CartonSummary> cartonPage(String customerId, ReturnInventoryRequests.Query request) {
required(customerId, "客户身份不能为空");
long total = cartons.merchantCount(customerId, request);
List<ReturnInventoryResponses.CartonSummary> records = total == 0 ? List.of() :
cartons.merchantPage(customerId, request, (request.page() - 1) * request.size()).stream()
.map(carton -> new ReturnInventoryResponses.CartonSummary(carton.getCartonMark(), carton.getOverseasWarehouseId(),
carton.getSourceBatchNo(), carton.getSourceReturnNo(), carton.getStatus(),
carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()),
carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt())).toList();
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@Transactional(readOnly = true)
public PageResult<ReturnInventoryResponses.SummaryItem> summaryPage(String customerId, ReturnInventoryRequests.SummaryQuery request) {
String warehouseId = text(request.overseasWarehouseId());
@@ -241,6 +254,8 @@ public class ReturnInventoryService {
Map<String, Long> remaining = new LinkedHashMap<>(requested);
Map<String, Integer> emptiedBySku = new LinkedHashMap<>();
Map<Long, Map<String, Long>> attachedByOrder = new LinkedHashMap<>();
LinkedHashSet<String> consumedBatchNos = new LinkedHashSet<>();
LinkedHashSet<String> consumedReturnNos = new LinkedHashSet<>();
long total = 0;
for (ReturnInventoryCarton source : sources) {
List<ReturnInventoryCartonItem> sourceItems = cartonItems.listCarton(source.getId());
@@ -249,6 +264,8 @@ public class ReturnInventoryService {
String sku = item.getSkuCode().toUpperCase(java.util.Locale.ROOT);
long take = Math.min(value(remaining.get(sku)), value(item.getQuantity()));
if (take > 0) {
consumedBatchNos.add(required(source.getSourceBatchNo(), "来源签收批次缺失,无法合箱"));
consumedReturnNos.add(required(source.getSourceReturnNo(), "来源退货单缺失,无法合箱"));
remaining.put(sku, remaining.get(sku) - take); total = Math.addExact(total, take);
ReturnOrder owner = sourceOwners.get(source.getId());
if (owner != null) attachedByOrder.computeIfAbsent(owner.getId(), ignored -> new LinkedHashMap<>())
@@ -270,7 +287,7 @@ public class ReturnInventoryService {
long targetId = IdWorker.getId();
ReturnInventoryCarton target = new ReturnInventoryCarton();
target.setId(targetId); target.setCustomerId(customerId); target.setOverseasWarehouseId(sources.get(0).getOverseasWarehouseId());
target.setCartonMark(numbers.next("RETURN_CARTON", target.getOverseasWarehouseId(), targetId)); target.setSourceBatchNo("MERGED"); target.setSourceReturnNo(String.join(",", returnNos)); target.setStatus(AVAILABLE);
target.setCartonMark(numbers.next("RETURN_CARTON", target.getOverseasWarehouseId(), targetId)); target.setSourceBatchNo(String.join(",", consumedBatchNos)); target.setSourceReturnNo(String.join(",", consumedReturnNos)); target.setStatus(AVAILABLE);
target.setSkuTypes(requested.size()); target.setTotalQuantity(total); target.setLength(request.length()); target.setWidth(request.width()); target.setHeight(request.height()); target.setWeight(request.weight()); target.setCreatedAt(now); target.setUpdatedAt(now); cartons.insert(target);
for (Map.Entry<String, Long> entry : requested.entrySet()) {
ReturnInventoryCartonItem targetItem = new ReturnInventoryCartonItem();
@@ -279,8 +296,9 @@ public class ReturnInventoryService {
// A SKU's carton count is the number of available physical cartons containing it:
// remove fully emptied source cartons, then add the one merged target carton.
long cartonDelta = 1L - emptiedBySku.getOrDefault(entry.getKey(), 0);
balances.adjust(IdWorker.getId(), customerId, target.getOverseasWarehouseId(), entry.getKey(), productNames.get(entry.getKey()), 0,
-entry.getValue(), cartonDelta);
if (balances.adjustCartonQuantity(customerId, target.getOverseasWarehouseId(), entry.getKey(), cartonDelta) != 1) {
throw new IllegalStateException("退货合箱库存箱数更新失败: " + entry.getKey());
}
}
attachMergedCarton(target, attachedByOrder, selectedOrders, productNames, now);
operation.setResultCartonId(targetId); operation.setUpdatedAt(now); operations.updateById(operation);
@@ -351,13 +369,14 @@ public class ReturnInventoryService {
for (ReturnInventoryResponses.CartonItem item : items) rows.append("<tr><td>").append(escape(item.skuCode())).append("</td><td>")
.append(escape(item.productNameZh())).append("</td><td class=\"qty\">").append(item.quantity()).append("</td></tr>");
return "<!doctype html><html><head><meta charset=\"utf-8\"><title>退货箱唛</title><style>"
+ "@page{size:100mm 150mm;margin:5mm}body{font-family:Arial,'Microsoft YaHei',sans-serif;color:#111;margin:0}.label{border:2px solid #111;padding:5mm;min-height:136mm}.brand{font-size:16px;font-weight:700}.title{font-size:22px;font-weight:700;margin:3mm 0 5mm}.mark{font:700 18px monospace;letter-spacing:1px;word-break:break-all}.meta{font-size:11px;margin:4mm 0;border-top:1px solid #111;border-bottom:1px solid #111;padding:3mm 0}.barcode{height:11mm;margin:3mm 0;background:repeating-linear-gradient(90deg,#111 0 2px,#fff 2px 4px,#111 4px 5px,#fff 5px 8px)}table{width:100%;border-collapse:collapse;font-size:11px}th,td{border:1px solid #111;padding:2mm;text-align:left}.qty{text-align:right;font-weight:700}.total{font-size:14px;font-weight:700;text-align:right;margin-top:4mm}</style></head><body><div class=\"label\"><div class=\"brand\">BAIYUE OVERSEAS WAREHOUSE</div><div class=\"title\">RETURN CARTON LABEL / 退货箱唛</div><div class=\"mark\">"
+ escape(carton.getCartonMark()) + "</div><div class=\"barcode\" aria-label=\"" + escape(carton.getCartonMark()) + "\"></div>"
+ "<div class=\"meta\">仓库:" + escape(carton.getOverseasWarehouseId()) + "<br>客户:" + escape(carton.getCustomerId())
+ "<br>箱规:" + escape(String.valueOf(carton.getLength())) + " × " + escape(String.valueOf(carton.getWidth())) + " × " + escape(String.valueOf(carton.getHeight())) + " cm / " + escape(String.valueOf(carton.getWeight())) + " kg"
+ "@page{size:100mm 150mm;margin:5mm}body{font-family:Arial,'Microsoft YaHei',sans-serif;color:#111;margin:0}.label{box-sizing:border-box;width:90mm;border:2px solid #111;padding:3mm;min-height:138mm}.brand{font-size:12px;font-weight:700}.title{font-size:16px;font-weight:700;margin:3mm 0}.mark{font:700 16px monospace;word-break:break-all}.meta{font-size:11px;margin:3mm 0;border-top:1px solid #111;border-bottom:1px solid #111;padding:2mm 0;overflow-wrap:anywhere}.barcode{margin:3mm 0}table{width:100%;border-collapse:collapse;font-size:11px;table-layout:fixed}th,td{border:1px solid #111;padding:1mm;text-align:left;overflow-wrap:anywhere}tr{break-inside:avoid}.qty{text-align:right;font-weight:700}.total{font-size:14px;font-weight:700;text-align:right;margin-top:3mm}</style></head><body><div class=\"label\"><div class=\"brand\">BAIYUE OVERSEAS WAREHOUSE</div><div class=\"title\">RETURN CARTON LABEL / 退货箱唛</div><div class=\"mark\">"
+ escape(carton.getCartonMark()) + "</div><div class=\"barcode\">" + ReturnCartonBarcode.svg(carton.getCartonMark()) + "</div>"
+ "<div class=\"meta\">来源退货单:" + escape(carton.getSourceReturnNo())
+ "<br>箱规:" + dimension(carton.getLength()) + " × " + dimension(carton.getWidth()) + " × " + dimension(carton.getHeight()) + " cm / " + dimension(carton.getWeight()) + " kg"
+ "<br>签收批次:" + escape(carton.getSourceBatchNo()) + "</div><table><thead><tr><th>SKU</th><th>品名</th><th>数量</th></tr></thead><tbody>"
+ rows + "</tbody></table><div class=\"total\">合计:" + carton.getTotalQuantity() + " 件 / " + carton.getSkuTypes() + " 种 SKU</div></div></body></html>";
}
private static String dimension(BigDecimal value) { return value == null ? "—" : value.stripTrailingZeros().toPlainString(); }
private String hash(Object request) { try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(String.valueOf(request).getBytes(StandardCharsets.UTF_8))); }
catch (Exception exception) { throw new IllegalStateException("无法生成请求哈希", exception); } }
@@ -118,12 +118,14 @@ public class ReturnReceiptService {
tail.setId(IdWorker.getId());
tail.setOrderNo(numbers.next("RETURN_RELABEL", returnOrder.getOverseasWarehouseId(), tail.getId()));
tail.setCustomerId(returnOrder.getCustomerId()); tail.setOrderType("RETURN_RELABEL");
tail.setSourceRegion("CUSTOMER"); tail.setSourceOrderId(String.valueOf(returnOrder.getId()));
tail.setSourceRegion("CUSTOMER"); tail.setSourceInboundId(returnOrder.getReturnNo());
tail.setSourceOrderId(String.valueOf(returnOrder.getId()));
tail.setSourceOrderNo(returnOrder.getReturnNo()); tail.setOverseasWarehouseId(returnOrder.getOverseasWarehouseId());
tail.setCurrentStatus("PENDING_ASSIGNMENT"); tail.setSubOrderStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "RETURN_RELABEL:PENDING_BINDING" : "RETURN_RELABEL:NOT_REQUIRED");
tail.setRelabelStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "PENDING_BINDING" : "NOT_REQUIRED");
tail.setCustomerRemark(returnOrder.getRemark()); tail.setSourceEventId("RETURN_ORDER:" + returnOrder.getReturnNo());
tail.setSourceLoadingPlanId("RETURN_RELABEL"); tail.setCreatedAt(now); tail.setUpdatedAt(now);
tail.setSourcePayloadHash(returnOrder.getRequestHash());
lastlegOrders.insert(tail);
returnOrder.setLastlegOrderId(tail.getId()); returnOrder.setLastlegOrderNo(tail.getOrderNo());
returnOrder.setRelabelStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "PENDING_BINDING" : "NOT_REQUIRED");
@@ -31,6 +31,8 @@ import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper;
import com.baiyue.lastleg.model.LastlegOrderType;
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@@ -75,6 +77,7 @@ public class LastlegOrderService {
@Resource private InboundOrderDetainedOutboxService detainedOutbox;
@Resource private ReceivingWarehouseOperationMapper receivingWarehouseOperations;
@Resource private WarehouseResourceGateway warehouseResourceGateway;
@Resource private ContainerShippingGateMapper containerShippingGates;
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
return page(customerId, query, null);
@@ -85,7 +88,9 @@ public class LastlegOrderService {
new Page<>(query.page(), query.size()), filter(customerId, query, relabelNo).orderByDesc(LastlegOrder::getCreatedAt));
Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> mergeRelations = palletMergeRelations(page.getRecords());
Map<Long, String> dispatchStatuses = dispatchStatuses(page.getRecords());
Map<String, String> containerNodes = containerNodes(page.getRecords());
return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()),
StringUtils.hasText(order.getSourceContainerBookingId()) ? containerNodes.get(order.getSourceContainerBookingId()) : null,
dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"))).toList(),
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
@@ -269,7 +274,7 @@ public class LastlegOrderService {
value.getNodeCode(), value.getNodeAt(), value.getSource()))
.sorted(java.util.Comparator.comparingInt(value -> MANUAL_NODE_CODES.indexOf(value.nodeCode()))).toList();
return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId()),
dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED")), manualNodes, cargo, history.stream()
containerNode(order), dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED")), manualNodes, cargo, history.stream()
.map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(),
value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream()
.map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(),
@@ -488,20 +493,22 @@ public class LastlegOrderService {
public void writeExportCsv(String customerId, LastlegOrderRequests.Query query, OutputStream output)
throws IOException {
output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8));
output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜节点,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8));
final int batchSize = 1000;
long page = 1;
while (true) {
List<LastlegOrder> orders = orderMapper.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, batchSize),
filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId)).getRecords();
if (orders.isEmpty()) break;
Map<String, String> containerNodes = containerNodes(orders);
for (LastlegOrder order : orders) {
String line = String.join(",", List.of(
csv(order.getId()), csv(order.getOrderNo()), csv(LastlegOrderType.labelOf(order.getOrderType())),
csv(displayInboundCode(order)),
csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()),
csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()),
csv(order.getContainerNo()), csv(order.getContainerStatus()), csv(order.getAbnormal()), csv(order.getDetained()),
csv(order.getContainerNo()), csv(StringUtils.hasText(order.getSourceContainerBookingId())
? containerNodes.get(order.getSourceContainerBookingId()) : null), csv(order.getAbnormal()), csv(order.getDetained()),
csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getVolumeCbm()), csv(order.getProductQuantity()),
csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()),
csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()),
@@ -571,7 +578,7 @@ public class LastlegOrderService {
return result;
}
private Map<Long, String> dispatchStatuses(List<LastlegOrder> orders) {
private Map<Long, String> dispatchStatuses(List<LastlegOrder> orders) {
if (orders.isEmpty()) return Map.of();
List<LastlegOrderMapper.DispatchStatusRow> rows = orderMapper.findDispatchStatuses(
orders.stream().map(LastlegOrder::getId).toList());
@@ -581,13 +588,14 @@ public class LastlegOrderService {
}
private LastlegOrderSummary summary(LastlegOrder o, List<LastlegOrderMapper.PalletMergeRelationRow> relations,
String dispatchStatus) {
String containerNode, String dispatchStatus) {
List<LastlegOrderMapper.PalletMergeRelationRow> related = relations == null ? List.of() : relations;
return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(),
LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(),
displayInboundCode(o), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(),
o.getOverseasWarehouseCode(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(),
containerNode,
o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(),
Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER",
o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
@@ -599,6 +607,25 @@ public class LastlegOrderService {
o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt());
}
private String containerNode(LastlegOrder order) {
if (!StringUtils.hasText(order.getSourceContainerBookingId())) return null;
ContainerShippingGate gate = containerShippingGates.selectById(order.getSourceContainerBookingId());
return gate == null ? null : gate.getStandardNode();
}
private Map<String, String> containerNodes(List<LastlegOrder> orders) {
List<String> bookingIds = orders.stream().map(LastlegOrder::getSourceContainerBookingId)
.filter(StringUtils::hasText).distinct().toList();
if (bookingIds.isEmpty()) return Map.of();
Map<String, String> result = new LinkedHashMap<>();
containerShippingGates.selectBatchIds(bookingIds).forEach(gate -> {
if (StringUtils.hasText(gate.getStandardNode())) {
result.put(gate.getContainerBookingId(), gate.getStandardNode());
}
});
return result;
}
private static String displayInboundCode(LastlegOrder order) {
String orderType = order.getOrderType();
return "RELABEL_APPLICATION".equals(orderType) || "LOCAL_LOOSEBOARD".equals(orderType)
@@ -253,7 +253,7 @@ public class LoadingCompletedConsumer {
order.setReceivingWarehouseVersion(0);
order.setFbaWarehouseCode("FBA".equals(destinationType) ? source.destinationWarehouseCode() : null);
order.setContainerNo(containerNo);
order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false);
order.setAbnormal(false); order.setDetained(false);
order.setPackageCount(source.packageCount());
order.setPalletCount("ONE_CLICK_DROPSHIP".equals(orderType)
? dropshipPallets.size() : Objects.requireNonNullElse(source.palletCount(), 0));
@@ -459,7 +459,10 @@ public class LoadingCompletedConsumer {
private List<DropshipRequests.Pallet> pallets(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) {
if (source.pallets() != null && !source.pallets().isEmpty()) {
return source.pallets().stream().map(pallet -> {
List<DropshipRequests.Pallet> result = new ArrayList<>();
JsonNode rawPallets = rawSource.path("pallets");
for (int palletIndex = 0; palletIndex < source.pallets().size(); palletIndex++) {
FirstLegLoadingPlanCompletedV1.PalletSnapshot pallet = source.pallets().get(palletIndex);
Map<String, DropshipRequests.SkuPackages> inventorySkus = new java.util.LinkedHashMap<>();
for (var sku : pallet.skuPackages()) {
inventorySkus.merge(sku.skuCode(), new DropshipRequests.SkuPackages(sku.skuCode(),
@@ -470,8 +473,13 @@ public class LoadingCompletedConsumer {
Math.addExact(left.productQuantity(), right.productQuantity()), List.copyOf(marks));
});
}
return new DropshipRequests.Pallet(pallet.palletNo(), List.copyOf(inventorySkus.values()));
}).toList();
JsonNode rawPallet = rawPallets.isArray() && palletIndex < rawPallets.size()
? rawPallets.get(palletIndex) : objectMapper.createObjectNode();
result.add(new DropshipRequests.Pallet(pallet.palletNo(), List.copyOf(inventorySkus.values()),
positiveManifest(rawPallet, "manifestVersion"), positiveManifest(rawPallet, "manifestPackageCount"),
positiveManifest(rawPallet, "manifestFragmentCount"), requiredManifestHash(rawPallet)));
}
return List.copyOf(result);
}
return legacyPallets(rawSource);
}
@@ -484,7 +492,9 @@ public class LoadingCompletedConsumer {
String palletNo = pallet.path("palletNo").asText(pallet.path("sourcePalletNo").asText(null));
List<DropshipRequests.SkuPackages> skuRows = skuPackages(pallet);
if (palletNo != null && !palletNo.isBlank() && !skuRows.isEmpty()) {
result.add(new DropshipRequests.Pallet(palletNo, skuRows));
result.add(new DropshipRequests.Pallet(palletNo, skuRows,
positiveManifest(pallet, "manifestVersion"), positiveManifest(pallet, "manifestPackageCount"),
positiveManifest(pallet, "manifestFragmentCount"), requiredManifestHash(pallet)));
}
}
return result;
@@ -492,11 +502,27 @@ public class LoadingCompletedConsumer {
String palletNo = source.path("palletNo").asText(source.path("sourcePalletNo").asText(null));
List<DropshipRequests.SkuPackages> skuRows = skuPackages(source);
if (palletNo != null && !palletNo.isBlank() && !skuRows.isEmpty()) {
result.add(new DropshipRequests.Pallet(palletNo, skuRows));
result.add(new DropshipRequests.Pallet(palletNo, skuRows,
positiveManifest(source, "manifestVersion"), positiveManifest(source, "manifestPackageCount"),
positiveManifest(source, "manifestFragmentCount"), requiredManifestHash(source)));
}
return result;
}
private int positiveManifest(JsonNode source, String field) {
int value = source.path(field).asInt(0);
if (value <= 0) throw new IllegalArgumentException("One-click dropship pallet is missing " + field);
return value;
}
private String requiredManifestHash(JsonNode source) {
String value = source.path("manifestHash").asText(null);
if (value == null || !value.matches("(?i)[0-9a-f]{64}")) {
throw new IllegalArgumentException("One-click dropship pallet manifestHash is invalid");
}
return value.toLowerCase(java.util.Locale.ROOT);
}
private List<DropshipRequests.SkuPackages> skuPackages(JsonNode source) {
List<DropshipRequests.SkuPackages> result = new ArrayList<>();
JsonNode nodes = source.path("skuPackages");