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