Merge remote-tracking branch 'origin/test' into test

# Conflicts:
#	lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
This commit is contained in:
2026-10-09 16:13:28 +08:00
39 changed files with 990 additions and 53 deletions
@@ -514,6 +514,7 @@ public class DeliveryTransferService {
existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (existing != null) return replayReturnOutbound(customerId, existing, requestHash);
for (ReturnInventoryCarton carton : locked) {
com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountGuard.requireConfirmed(carton);
if (!"AVAILABLE".equals(carton.getStatus())) throw conflict("退货箱已被其他作业占用:" + carton.getCartonMark());
var contents = returnCartonItems.lockCarton(carton.getId());
if (contents.isEmpty() || contents.stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0))
@@ -13,8 +13,13 @@ public record LastlegOrderDetailResponse(
List<LastlegOrderDetail> cargo,
List<History> statusHistory,
List<OverseasFeeSource> fees,
List<Operation> operationHistory
List<Operation> operationHistory,
com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses.Detail returnReceipt
) {
public LastlegOrderDetailResponse(LastlegOrderSummary order, List<LastlegOrderCommandDtos.ManualNodeView> manualNodes,
List<LastlegOrderDetail> cargo, List<History> statusHistory, List<OverseasFeeSource> fees, List<Operation> operationHistory) {
this(order, manualNodes, cargo, statusHistory, fees, operationHistory, null);
}
public record History(Long id, String fromStatus, String toStatus, String remark, LocalDateTime createdAt) {}
public record Operation(Long id, String operationNode, String operatorName, String result, LocalDateTime createdAt) {}
}
@@ -615,6 +615,13 @@ public class PdaRelabelService {
}
private PdaRelabelTask requireOwnedTask(RelabelApplication application, String operatorKey, boolean lock) {
for (RelabelItem source : items.selectList(new LambdaQueryWrapper<RelabelItem>()
.eq(RelabelItem::getApplicationId, application.getId()).eq(RelabelItem::getCustomerId, application.getCustomerId()))) {
if (source.getReturnCartonId() == null) continue;
ReturnInventoryCarton carton = returnCartons.selectById(source.getReturnCartonId());
if (carton == null) throw conflict("退货来源箱不存在");
com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountGuard.requireConfirmed(carton);
}
if (!PROCESSING.equals(application.getStatus())) {
throw conflict("Relabel application is no longer executable");
}
@@ -334,6 +334,8 @@ public class RelabelApplicationService implements WalletResultHandler {
private RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request,
Long existingTailOrderId) {
if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required");
if (request.items().stream().map(RelabelApplicationService::operationType).distinct().count() != 1)
throw badRequest("一张贴标申请只能选择一种换标类型,不能混合提交只换箱唛、只换 SKU 或 SKU 和箱唛都换");
LastlegOrder linkedSourceOrder = existingTailOrderId == null ? null : orders.selectById(existingTailOrderId);
boolean warehouseTransferRelabel = linkedSourceOrder != null
&& "WAREHOUSE_TRANSFER".equals(linkedSourceOrder.getOrderType());
@@ -393,6 +395,7 @@ public class RelabelApplicationService implements WalletResultHandler {
}
if (!carton.getCartonMark().equals(item.cartonMark().trim()))
throw conflict("来源退货箱号与选择的库存不一致");
com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountGuard.requireConfirmed(carton);
if ("PARCEL".equals(carton.getSourceUnitType()) && !"SKU_LABEL".equals(item.operationType()))
throw conflict("散件退货库存只能贴 SKU,请先合箱后申请箱唛或混贴");
final long sourceReturnCartonId = carton.getId();
@@ -642,6 +645,7 @@ public class RelabelApplicationService implements WalletResultHandler {
} else if ("COMPLETED".equals(next)) {
boolean preDispatched = "PRE_DISPATCH_CREATED".equals(application.getFulfillmentStatus());
application.setFulfillmentStatus("OUTBOUNDED");
applications.updateById(application);
if (!StringUtils.hasText(application.getOutboundOrderNo())
&& !preDispatched) {
releaseInventory(customerId, operatorId, application);
@@ -1037,6 +1041,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|| !"RELABEL_PENDING".equals(carton.getStatus())
|| intValue(source.getNotifiedPackageCount()) != 1)
throw conflict("退货整箱换标出库必须覆盖该箱全部 SKU 数量:" + source.getCartonMark());
com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountGuard.requireConfirmed(carton);
}
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId());
@@ -22,6 +22,38 @@ import org.springframework.web.bind.annotation.RestController;
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public class PdaReturnInventoryController {
@Resource private ReturnInventoryService service;
@Resource private com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountService counting;
@GetMapping("/counting/tasks")
public ApiResponse<com.baiyue.common.core.api.PageResult<com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Task>> countingTasks(
@AuthenticationPrincipal AuthenticatedUser user, @Valid com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Query query) {
return ApiResponse.success(counting.page(PdaWarehouseIdentity.require(user), query));
}
@GetMapping("/counting/cartons/{cartonMark}")
public ApiResponse<com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Task> countingCarton(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String cartonMark) {
return ApiResponse.success(counting.scan(PdaWarehouseIdentity.require(user), cartonMark));
}
@GetMapping("/counting/scan/{code}")
public ApiResponse<java.util.List<com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Task>> scanCounting(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String code) {
return ApiResponse.success(counting.scanTasks(PdaWarehouseIdentity.require(user), code));
}
@GetMapping("/counting/current")
public ApiResponse<com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Task> currentCounting(@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(counting.current(PdaWarehouseIdentity.require(user)));
}
@PostMapping("/counting/receipts/{receiptNo}/begin")
public ApiResponse<com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Task> beginCounting(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String receiptNo) {
return ApiResponse.success(counting.begin(PdaWarehouseIdentity.require(user), receiptNo));
}
@PostMapping("/counting/receipts/{receiptNo}")
public ApiResponse<com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Task> confirmCounting(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String receiptNo,
@Valid @RequestBody com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Confirm request) {
return ApiResponse.success(counting.confirm(PdaWarehouseIdentity.require(user), receiptNo, request));
}
@GetMapping("/packing/orders")
public ApiResponse<ReturnInventoryResponses.PackingPreview> packingOrders(@AuthenticationPrincipal AuthenticatedUser user,
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.returnreceipt.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;
public final class ReturnCartonCountDtos {
private ReturnCartonCountDtos() { }
public record Query(@Min(1) long page, @Min(1) @Max(50) long size, @Size(max=128) String keyword) { }
public record Sku(String skuCode, String productNameZh, long quantity) { }
public record Box(String cartonMark, List<Sku> items) { }
public record Task(String receiptNo, String returnNo, String palletCode, String status, List<Sku> expectedItems, List<Box> boxes) { }
public record CountItem(@NotBlank @Size(max=128) String skuCode, @Min(1) long quantity) { }
public record CountBox(@NotBlank @Size(max=128) String cartonMark, @NotEmpty @Size(max=100) List<@NotNull @Valid CountItem> items) { }
public record Confirm(@NotBlank @Size(max=128) String idempotencyKey, @NotEmpty @Size(max=500) List<@NotNull @Valid CountBox> boxes) { }
}
@@ -14,11 +14,16 @@ public final class ReturnOrderResponses {
public record Item(String skuCode, String productNameZh, long expectedQuantity,
long receivedQuantity) { }
public record Detail(Summary order, String remark, List<Item> items, List<Receipt> receipts, List<PalletDetail> pallets, List<Photo> photos,
List<PackingDetail> packingDetails) {
List<PackingDetail> packingDetails, List<ReceiptCarton> cartons) {
public Detail(Summary order, String remark, List<Item> items, List<Receipt> receipts, List<PalletDetail> pallets, List<Photo> photos, List<PackingDetail> packingDetails) {
this(order, remark, items, receipts, pallets, photos, packingDetails, List.of());
}
public Detail(Summary order, String remark, List<Item> items, List<Receipt> receipts, List<PalletDetail> pallets, List<Photo> photos) {
this(order, remark, items, receipts, pallets, photos, List.of());
}
}
public record ReceiptCarton(String receiptNo, String palletCode, String cartonMark, String countStatus,
LocalDateTime countedAt, List<ReceiptItem> items) { }
public record PackingDetail(String sourceCartonMark, String targetCartonMark, String returnNo,
String skuCode, long quantity, LocalDateTime createdAt) { }
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
@@ -30,6 +30,9 @@ public class ReturnInventoryCarton {
private String relabelStatus;
private LocalDateTime relabelCompletedAt;
private String status;
private String countStatus = "CONFIRMED";
private LocalDateTime countedAt;
private String countedBy;
private Integer skuTypes;
private Long totalQuantity;
private BigDecimal length;
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
/** Locks one durable work session per operator, including when no task is active. */
@Mapper
public interface ReturnCartonCountSessionMapper {
record Session(String operatorId, String warehouseId, Long receiptId, LocalDateTime startedAt) { }
@Insert("INSERT INTO ll_return_carton_count_session(operator_id,warehouse_id,created_at,updated_at) " +
"VALUES(#{operator},#{warehouse},NOW(3),NOW(3)) ON DUPLICATE KEY UPDATE operator_id=operator_id")
int ensure(@Param("operator") String operator, @Param("warehouse") String warehouse);
@Select("SELECT operator_id,warehouse_id,receipt_id,started_at FROM ll_return_carton_count_session WHERE operator_id=#{operator} FOR UPDATE")
Session lockOperator(@Param("operator") String operator);
@Select("SELECT operator_id,warehouse_id,receipt_id,started_at FROM ll_return_carton_count_session WHERE operator_id=#{operator}")
Session findOperator(@Param("operator") String operator);
@Select("SELECT operator_id,warehouse_id,receipt_id,started_at FROM ll_return_carton_count_session WHERE receipt_id=#{receiptId}")
Session findReceipt(@Param("receiptId") long receiptId);
@Update("UPDATE ll_return_carton_count_session SET receipt_id=#{receiptId},warehouse_id=#{warehouse},started_at=NOW(3),updated_at=NOW(3) WHERE operator_id=#{operator} AND receipt_id IS NULL")
int claim(@Param("operator") String operator, @Param("warehouse") String warehouse, @Param("receiptId") long receiptId);
@Update("UPDATE ll_return_carton_count_session SET receipt_id=NULL,started_at=NULL,updated_at=NOW(3) WHERE operator_id=#{operator} AND receipt_id=#{receiptId}")
int release(@Param("operator") String operator, @Param("receiptId") long receiptId);
}
@@ -16,7 +16,7 @@ public interface ReturnInventoryBalanceMapper extends BaseMapper<ReturnInventory
"LEAST(b.available_quantity,COALESCE((SELECT SUM(GREATEST(0,i.quantity-COALESCE(i.packing_reserved_quantity,0))) " +
"FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id AND i.customer_id=c.customer_id " +
"WHERE c.customer_id=b.customer_id AND c.overseas_warehouse_id=b.overseas_warehouse_id AND i.sku_code=b.sku_code " +
"AND c.status='AVAILABLE'),0)) AS available_quantity";
"AND c.status='AVAILABLE' AND c.count_status='CONFIRMED'),0)) AS available_quantity";
@Select("SELECT * FROM ll_return_inventory_balance WHERE customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{warehouseId} AND sku_code=#{skuCode} FOR UPDATE")
ReturnInventoryBalance lockSku(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@@ -11,6 +11,8 @@ import java.util.List;
@Mapper
public interface ReturnInventoryCartonItemMapper extends BaseMapper<ReturnInventoryCartonItem> {
@org.apache.ibatis.annotations.Delete("DELETE FROM ll_return_inventory_carton_item WHERE carton_id=#{cartonId}")
int deleteCarton(@Param("cartonId") long cartonId);
@Select("SELECT COALESCE(SUM(GREATEST(0,i.quantity-COALESCE(i.packing_reserved_quantity,0))),0) " +
"FROM ll_return_inventory_carton_item i JOIN ll_return_inventory_carton c ON c.id=i.carton_id " +
"WHERE c.customer_id=#{customerId} AND c.overseas_warehouse_id=#{warehouseId} " +
@@ -10,7 +10,9 @@ import java.util.List;
@Mapper
public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryCarton> {
@Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND status='AVAILABLE' ORDER BY carton_mark")
@Select("SELECT * FROM ll_return_inventory_carton WHERE source_receipt_id=#{receiptId} ORDER BY id FOR UPDATE")
List<ReturnInventoryCarton> lockReceipt(@Param("receiptId") long receiptId);
@Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND status='AVAILABLE' AND count_status='CONFIRMED' ORDER BY carton_mark")
List<ReturnInventoryCarton> packingAvailable(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId);
@Update("UPDATE ll_return_inventory_carton SET location_code=NULL,putaway_at=NULL WHERE id=#{id}")
@@ -20,7 +22,7 @@ public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryC
"<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.status != null and query.status != \"\"'>AND (CASE WHEN c.count_status='PENDING' AND c.status='AVAILABLE' THEN 'PENDING_COUNT' ELSE c.status END)=#{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>")
@@ -31,7 +33,7 @@ public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryC
List<ReturnInventoryCarton> merchantPage(@Param("customerId") String customerId,
@Param("query") com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests.Query query, @Param("offset") long offset);
@Select("<script>SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND status='AVAILABLE' " +
@Select("<script>SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND status='AVAILABLE' AND count_status='CONFIRMED' " +
"<if test='warehouseId != null and warehouseId != \"\"'>AND overseas_warehouse_id=#{warehouseId} </if>" +
"ORDER BY created_at DESC,id DESC LIMIT 500</script>")
List<ReturnInventoryCarton> findAvailable(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId);
@@ -44,7 +46,7 @@ public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryC
@Select("SELECT * FROM ll_return_inventory_carton WHERE id=#{id} FOR UPDATE")
ReturnInventoryCarton lockId(@Param("id") long id);
@Update("UPDATE ll_return_inventory_carton SET status='DISCARD_PENDING',updated_at=NOW(3) WHERE id=#{id} AND customer_id=#{customerId} AND status='AVAILABLE'")
@Update("UPDATE ll_return_inventory_carton SET status='DISCARD_PENDING',updated_at=NOW(3) WHERE id=#{id} AND customer_id=#{customerId} AND status='AVAILABLE' AND count_status='CONFIRMED'")
int markDiscardPending(@Param("id") long id, @Param("customerId") String customerId);
@Update("UPDATE ll_return_inventory_carton SET status=#{status},total_quantity=#{totalQuantity},sku_types=#{skuTypes},updated_at=NOW(3) WHERE id=#{id} AND customer_id=#{customerId} AND status='DISCARD_PENDING'")
@@ -71,7 +73,7 @@ public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryC
@Update("UPDATE ll_return_inventory_carton SET relabel_application_id=#{applicationId},relabel_no=#{relabelNo}," +
"relabel_status='PENDING',status='RELABEL_PENDING',updated_at=#{updatedAt} " +
"WHERE id=#{id} AND status='AVAILABLE' AND sku_types>0 AND total_quantity>0 AND relabel_application_id IS NULL")
"WHERE id=#{id} AND status='AVAILABLE' AND count_status='CONFIRMED' AND sku_types>0 AND total_quantity>0 AND relabel_application_id IS NULL")
int markRelabelPending(@Param("id") long id, @Param("applicationId") long applicationId,
@Param("relabelNo") String relabelNo, @Param("updatedAt") java.time.LocalDateTime updatedAt);
@@ -81,12 +83,13 @@ public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryC
@Select("SELECT * FROM ll_return_inventory_carton WHERE source_receipt_id=#{receiptId} ORDER BY id")
List<ReturnInventoryCarton> listReceipt(@Param("receiptId") long receiptId);
@Select("SELECT c.* FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.customer_id=#{customerId} AND c.overseas_warehouse_id=#{warehouseId} AND c.pallet_code=#{palletCode} AND c.status='AVAILABLE' ORDER BY c.id LIMIT 1 FOR UPDATE")
@Select("SELECT c.* FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.customer_id=#{customerId} AND c.overseas_warehouse_id=#{warehouseId} AND c.pallet_code=#{palletCode} AND c.status='AVAILABLE' AND c.count_status='CONFIRMED' ORDER BY c.id LIMIT 1 FOR UPDATE")
ReturnInventoryCarton findPalletSku(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode);
@Select("SELECT * FROM ll_return_inventory_carton WHERE overseas_warehouse_id=#{warehouseId} AND pallet_code=#{palletCode} AND status='AVAILABLE' ORDER BY id LIMIT 1")
@Select("SELECT * FROM ll_return_inventory_carton WHERE overseas_warehouse_id=#{warehouseId} AND pallet_code=#{palletCode} AND status='AVAILABLE' AND count_status='CONFIRMED' ORDER BY id LIMIT 1")
ReturnInventoryCarton findPalletSkuForCheck(@Param("customerId") String ignoredCustomerId, @Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode);
// Carrier ownership checks must include boxes still waiting for contents counting.
@Select("SELECT * FROM ll_return_inventory_carton WHERE overseas_warehouse_id=#{warehouseId} AND pallet_code=#{palletCode} AND status='AVAILABLE' ORDER BY id")
List<ReturnInventoryCarton> listPalletInventory(@Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode);
@@ -103,7 +106,7 @@ public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryC
"<if test='warehouseId != null and warehouseId != \"\"'>AND c.overseas_warehouse_id=#{warehouseId} </if>" +
"<if test='cartonMark != null and cartonMark != \"\"'>AND c.carton_mark LIKE CONCAT('%',#{cartonMark},'%') </if>" +
"<if test='returnNo != null and returnNo != \"\"'>AND c.source_return_no LIKE CONCAT('%',#{returnNo},'%') </if>" +
"<if test='status != null and status != \"\"'>AND c.status=#{status} </if>" +
"<if test='status != null and status != \"\"'>AND (CASE WHEN c.count_status='PENDING' AND c.status='AVAILABLE' THEN 'PENDING_COUNT' ELSE c.status END)=#{status} </if>" +
"<if test='skuCode != null and skuCode != \"\"'>AND EXISTS(SELECT 1 FROM ll_return_inventory_carton_item i WHERE i.carton_id=c.id AND i.sku_code LIKE CONCAT('%',#{skuCode},'%')) </if></script>")
long platformCount(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("cartonMark") String cartonMark, @Param("returnNo") String returnNo, @Param("status") String status, @Param("skuCode") String skuCode);
@@ -113,7 +116,7 @@ public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryC
"<if test='warehouseId != null and warehouseId != \"\"'>AND c.overseas_warehouse_id=#{warehouseId} </if>" +
"<if test='cartonMark != null and cartonMark != \"\"'>AND c.carton_mark LIKE CONCAT('%',#{cartonMark},'%') </if>" +
"<if test='returnNo != null and returnNo != \"\"'>AND c.source_return_no LIKE CONCAT('%',#{returnNo},'%') </if>" +
"<if test='status != null and status != \"\"'>AND c.status=#{status} </if>" +
"<if test='status != null and status != \"\"'>AND (CASE WHEN c.count_status='PENDING' AND c.status='AVAILABLE' THEN 'PENDING_COUNT' ELSE c.status END)=#{status} </if>" +
"<if test='skuCode != null and skuCode != \"\"'>AND EXISTS(SELECT 1 FROM ll_return_inventory_carton_item i WHERE i.carton_id=c.id AND i.sku_code LIKE CONCAT('%',#{skuCode},'%')) </if>" +
"ORDER BY c.created_at DESC,c.id DESC LIMIT #{offset},#{size}</script>")
List<ReturnInventoryCarton> platformPage(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@@ -8,6 +8,11 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface ReturnInventoryOperationMapper extends BaseMapper<ReturnInventoryOperation> {
@Select("SELECT o.* FROM ll_return_inventory_operation o JOIN ll_return_inventory_carton c ON c.id=o.result_carton_id " +
"WHERE c.source_receipt_id=#{receiptId} AND c.customer_id=#{customerId} AND o.operation_type IN ('COUNT_CONTENTS','SIGN_CONTENTS') " +
"ORDER BY CASE WHEN o.operation_type='COUNT_CONTENTS' THEN 0 ELSE 1 END, o.created_at DESC, o.id DESC LIMIT 1")
ReturnInventoryOperation receiptContents(@Param("customerId") String customerId, @Param("receiptId") long receiptId);
@Select("SELECT * FROM ll_return_inventory_operation WHERE operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1")
ReturnInventoryOperation findKey(@Param("operatorId") String operatorId, @Param("key") String key);
@@ -11,7 +11,7 @@ import java.util.List;
public interface ReturnInventoryQueryMapper {
@Select("<script>SELECT COUNT(*) FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id " +
"WHERE c.customer_id=#{customerId} " +
"<if test='status != null and status != \"\"'>AND c.status=#{status} </if>" +
"<if test='status != null and status != \"\"'>AND (CASE WHEN c.count_status='PENDING' AND c.status='AVAILABLE' THEN 'PENDING_COUNT' ELSE c.status END)=#{status} </if>" +
"<if test='returnNo != null and returnNo != \"\"'>AND (c.source_return_no LIKE CONCAT('%',#{returnNo},'%') OR EXISTS (SELECT 1 FROM ll_return_receipt r JOIN ll_return_order o ON o.id=r.return_order_id WHERE r.id=c.source_receipt_id AND o.return_no LIKE CONCAT('%',#{returnNo},'%'))) </if>" +
"<if test='warehouseId != null and warehouseId != \"\"'>AND c.overseas_warehouse_id=#{warehouseId} </if>" +
"<if test='skuCode != null and skuCode != \"\"'>AND i.sku_code LIKE CONCAT('%',#{skuCode},'%') </if>" +
@@ -20,10 +20,10 @@ public interface ReturnInventoryQueryMapper {
@Param("skuCode") String skuCode, @Param("cartonMark") String cartonMark,
@Param("status") String status, @Param("returnNo") String returnNo);
@Select("<script>SELECT c.id carton_id,c.overseas_warehouse_id,c.carton_mark,c.source_batch_no,c.status,c.sku_types,c.total_quantity,c.created_at, " +
@Select("<script>SELECT c.id carton_id,c.overseas_warehouse_id,c.carton_mark,c.source_batch_no,(CASE WHEN c.count_status='PENDING' AND c.status='AVAILABLE' THEN 'PENDING_COUNT' ELSE c.status END) AS status,c.sku_types,c.total_quantity,c.created_at, " +
"i.sku_code,i.product_name_zh,i.quantity FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id " +
"WHERE c.customer_id=#{customerId} " +
"<if test='status != null and status != \"\"'>AND c.status=#{status} </if>" +
"<if test='status != null and status != \"\"'>AND (CASE WHEN c.count_status='PENDING' AND c.status='AVAILABLE' THEN 'PENDING_COUNT' ELSE c.status END)=#{status} </if>" +
"<if test='returnNo != null and returnNo != \"\"'>AND (c.source_return_no LIKE CONCAT('%',#{returnNo},'%') OR EXISTS (SELECT 1 FROM ll_return_receipt r JOIN ll_return_order o ON o.id=r.return_order_id WHERE r.id=c.source_receipt_id AND o.return_no LIKE CONCAT('%',#{returnNo},'%'))) </if>" +
"<if test='warehouseId != null and warehouseId != \"\"'>AND c.overseas_warehouse_id=#{warehouseId} </if>" +
"<if test='skuCode != null and skuCode != \"\"'>AND i.sku_code LIKE CONCAT('%',#{skuCode},'%') </if>" +
@@ -10,6 +10,23 @@ import java.util.List;
@Mapper
public interface ReturnReceiptMapper extends BaseMapper<ReturnReceipt> {
@Select("SELECT r.* FROM ll_return_receipt r WHERE r.unit_type='PALLET' AND EXISTS(" +
"SELECT 1 FROM ll_return_inventory_carton c WHERE c.source_receipt_id=r.id " +
"AND c.overseas_warehouse_id=#{warehouseId} AND c.count_status='PENDING' AND c.status='AVAILABLE' " +
"AND (c.carton_mark=#{code} OR c.pallet_code=#{code})) ORDER BY r.signed_at,r.id")
List<ReturnReceipt> scanPendingContents(@Param("warehouseId") String warehouseId, @Param("code") String code);
@Select("SELECT * FROM ll_return_receipt WHERE receipt_no=#{receiptNo} AND unit_type='PALLET' FOR UPDATE")
ReturnReceipt lockCountReceipt(@Param("receiptNo") String receiptNo);
String COUNT_TASK_FILTER = " FROM ll_return_receipt r WHERE r.unit_type='PALLET' AND EXISTS(SELECT 1 FROM ll_return_inventory_carton c " +
"WHERE c.source_receipt_id=r.id AND c.overseas_warehouse_id=#{warehouseId} AND c.count_status='PENDING' AND c.status='AVAILABLE' " +
"<if test='keyword != null and keyword != \"\"'>AND (c.carton_mark LIKE CONCAT('%',#{keyword},'%') OR c.pallet_code LIKE CONCAT('%',#{keyword},'%') OR c.source_return_no LIKE CONCAT('%',#{keyword},'%'))</if>) ";
@Select("<script>SELECT COUNT(*)" + COUNT_TASK_FILTER + "</script>")
long countPendingContents(@Param("warehouseId") String warehouseId, @Param("keyword") String keyword);
@Select("<script>SELECT r.*" + COUNT_TASK_FILTER + "ORDER BY r.signed_at,r.id LIMIT #{offset},#{size}</script>")
List<ReturnReceipt> pendingContents(@Param("warehouseId") String warehouseId, @Param("keyword") String keyword,
@Param("offset") long offset, @Param("size") long size);
@Select("SELECT * FROM ll_return_receipt WHERE operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1")
ReturnReceipt findKey(@Param("operatorId") String operatorId, @Param("key") String key);
@Select("SELECT * FROM ll_return_receipt WHERE operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1 FOR UPDATE")
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.returnreceipt.service;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public final class ReturnCartonCountGuard {
private ReturnCartonCountGuard() { }
public static void requireConfirmed(ReturnInventoryCarton carton) {
if (!"CONFIRMED".equals(carton.getCountStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "退货箱尚未完成箱内清点,请先到 PDA 退货箱内清点作业:" + carton.getCartonMark());
}
}
}
@@ -0,0 +1,172 @@
package com.baiyue.lastleg.returnreceipt.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.*;
import com.baiyue.lastleg.returnreceipt.entity.*;
import com.baiyue.lastleg.returnreceipt.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.*;
/** Box quantities are released together after reconciliation to the immutable pallet receipt totals. */
@Service
public class ReturnCartonCountService {
@Resource private ReturnReceiptMapper receipts;
@Resource private ReturnReceiptItemMapper receiptItems;
@Resource private ReturnInventoryCartonMapper cartons;
@Resource private ReturnInventoryCartonItemMapper items;
@Resource private ReturnInventoryBalanceMapper balances;
@Resource private ReturnInventoryOperationMapper operations;
@Resource private ReturnCartonCountSessionMapper sessions;
@Resource private ObjectMapper objectMapper;
@Transactional(readOnly=true)
public PageResult<Task> page(PdaWarehouseIdentity identity, Query query) {
String warehouse = Long.toString(identity.overseasWarehouseId());
long total = receipts.countPendingContents(warehouse, query.keyword());
List<Task> tasks = receipts.pendingContents(warehouse, query.keyword(), (query.page()-1)*query.size(), query.size()).stream()
.map(receipt -> task(receipt, cartons.listReceipt(receipt.getId()))).toList();
return new PageResult<>(tasks, query.page(), query.size(), total, (total+query.size()-1)/query.size());
}
@Transactional(readOnly=true)
public List<Task> scanTasks(PdaWarehouseIdentity identity, String code) {
if (code == null || code.isBlank() || code.trim().length() > 128) throw error(HttpStatus.BAD_REQUEST, "请扫描有效的箱号或托盘号");
return receipts.scanPendingContents(Long.toString(identity.overseasWarehouseId()), code.trim()).stream()
.map(receipt -> task(receipt, cartons.listReceipt(receipt.getId()))).toList();
}
@Transactional(readOnly=true)
public Task scan(PdaWarehouseIdentity identity, String mark) {
ReturnInventoryCarton carton = cartons.findMark(mark.trim());
if (carton == null || !Long.toString(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw error(HttpStatus.NOT_FOUND, "当前仓没有该退货箱");
if (!"PALLET".equals(carton.getSourceUnitType()) || carton.getSourceReceiptId() == null) throw error(HttpStatus.CONFLICT, "该箱不属于托盘签收清点任务");
ReturnReceipt receipt = receipts.selectById(carton.getSourceReceiptId());
if (receipt == null) throw error(HttpStatus.NOT_FOUND, "签收批次不存在");
return task(receipt, cartons.listReceipt(receipt.getId()));
}
@Transactional
public Task begin(PdaWarehouseIdentity identity, String receiptNo) {
var session = lockSession(identity);
ReturnReceipt receipt = receipts.lockCountReceipt(receiptNo);
if (receipt == null) throw error(HttpStatus.NOT_FOUND, "清点任务不存在");
if (session.receiptId() != null && !session.receiptId().equals(receipt.getId()))
throw error(HttpStatus.CONFLICT, "您有未清点完成的任务,请先继续当前清点;一次只能清点一单");
List<ReturnInventoryCarton> boxes = cartons.lockReceipt(receipt.getId());
requireWarehouse(identity, receipt, boxes);
if (boxes.stream().anyMatch(box -> !"PENDING".equals(box.getCountStatus()) || !"AVAILABLE".equals(box.getStatus())))
throw error(HttpStatus.CONFLICT, "该任务无需清点或箱子已被其他作业占用,请刷新任务");
var owner = sessions.findReceipt(receipt.getId());
if (owner != null && !identity.operatorKey().equals(owner.operatorId()))
throw error(HttpStatus.CONFLICT, "该任务正在由其他人员清点");
if (session.receiptId() == null && sessions.claim(identity.operatorKey(), Long.toString(identity.overseasWarehouseId()), receipt.getId()) != 1)
throw error(HttpStatus.CONFLICT, "当前清点任务已变化,请重试");
return task(receipt, boxes);
}
@Transactional(readOnly=true)
public Task current(PdaWarehouseIdentity identity) {
var session = sessions.findOperator(identity.operatorKey());
if (session == null || session.receiptId() == null) return null;
if (!Long.toString(identity.overseasWarehouseId()).equals(session.warehouseId()))
throw error(HttpStatus.CONFLICT, "您在其他仓有未完成的清点任务,请先完成原任务");
ReturnReceipt receipt = receipts.selectById(session.receiptId());
if (receipt == null) throw error(HttpStatus.CONFLICT, "当前清点任务已异常,请核实原任务");
var boxes = cartons.listReceipt(receipt.getId());
requireWarehouse(identity, receipt, boxes);
return task(receipt, boxes);
}
private ReturnCartonCountSessionMapper.Session lockSession(PdaWarehouseIdentity identity) {
sessions.ensure(identity.operatorKey(), Long.toString(identity.overseasWarehouseId()));
var session = sessions.lockOperator(identity.operatorKey());
if (session == null) throw error(HttpStatus.CONFLICT, "清点会话未建立,请重试");
return session;
}
private void requireWarehouse(PdaWarehouseIdentity identity, ReturnReceipt receipt, List<ReturnInventoryCarton> boxes) {
if (boxes.isEmpty() || boxes.stream().anyMatch(box -> !Long.toString(identity.overseasWarehouseId()).equals(box.getOverseasWarehouseId()) || !receipt.getCustomerId().equals(box.getCustomerId())))
throw error(HttpStatus.NOT_FOUND, "当前仓没有该托盘签收任务");
}
@Transactional
public Task confirm(PdaWarehouseIdentity identity, String receiptNo, Confirm request) {
var session = lockSession(identity);
ReturnReceipt receipt = receipts.lockCountReceipt(receiptNo);
if (receipt == null) throw error(HttpStatus.NOT_FOUND, "托盘签收任务不存在");
List<ReturnInventoryCarton> boxes = cartons.lockReceipt(receipt.getId());
requireWarehouse(identity, receipt, boxes);
String hash = payload(List.of(receiptNo, request.boxes()));
ReturnInventoryOperation replay = operations.lockKey(identity.operatorKey(), request.idempotencyKey());
if (replay != null) {
if (!"COUNT_CONTENTS".equals(replay.getOperationType()) || !hash.equals(replay.getRequestHash())) throw error(HttpStatus.CONFLICT, "该幂等键已用于不同的清点内容");
try { return objectMapper.readValue(replay.getResultJson(), Task.class); }
catch (Exception error) { throw new IllegalStateException("清点结果读取失败", error); }
}
if (!Objects.equals(session.receiptId(), receipt.getId()) || !Long.toString(identity.overseasWarehouseId()).equals(session.warehouseId()))
throw error(HttpStatus.CONFLICT, "请先开始本单清点;未完成当前单前不能清点其他单");
if (boxes.stream().anyMatch(box -> !"PENDING".equals(box.getCountStatus()) || !"AVAILABLE".equals(box.getStatus()) || box.getRelabelApplicationId() != null)) throw error(HttpStatus.CONFLICT, "箱子已清点或被其他作业占用,请刷新任务");
Map<String, ReturnInventoryCarton> byMark = new LinkedHashMap<>(); boxes.forEach(box -> byMark.put(box.getCartonMark(), box));
if (request.boxes().size() != boxes.size()) throw error(HttpStatus.BAD_REQUEST, "必须清点本次托盘签收的全部箱子");
Map<String, Long> expected = new TreeMap<>(), actual = new TreeMap<>(), oldCounts = new TreeMap<>(), newCounts = new TreeMap<>();
Map<String, String> names = new HashMap<>();
for (ReturnReceiptItem line : receiptItems.listReceipt(receipt.getId())) expected.merge(line.getSkuCode(), line.getReceivedQuantity(), Math::addExact);
for (ReturnInventoryCarton box : boxes) for (ReturnInventoryCartonItem line : items.lockCarton(box.getId())) {
if (line.getPackingReservedQuantity() != null && line.getPackingReservedQuantity() > 0) throw error(HttpStatus.CONFLICT, "来源箱已被合箱计划占用");
oldCounts.merge(line.getSkuCode(), 1L, Long::sum); names.put(line.getSkuCode(), line.getProductNameZh());
}
Set<String> marks = new HashSet<>();
for (CountBox box : request.boxes()) {
if (!byMark.containsKey(box.cartonMark()) || !marks.add(box.cartonMark())) throw error(HttpStatus.BAD_REQUEST, "箱号不属于本次签收或重复填写");
Set<String> skus = new HashSet<>();
for (CountItem line : box.items()) {
if (!expected.containsKey(line.skuCode()) || !skus.add(line.skuCode()) || line.quantity() <= 0) throw error(HttpStatus.BAD_REQUEST, "SKU 不属于本次签收、数量无效或同箱重复");
actual.merge(line.skuCode(), line.quantity(), Math::addExact); newCounts.merge(line.skuCode(), 1L, Long::sum);
}
}
if (!expected.equals(actual)) throw error(HttpStatus.CONFLICT, "各箱 SKU 数量汇总必须与本托签收数量一致;签收=" + expected + ",清点=" + actual);
LocalDateTime now = LocalDateTime.now();
for (CountBox input : request.boxes()) {
ReturnInventoryCarton box = byMark.get(input.cartonMark()); items.deleteCarton(box.getId());
for (CountItem line : input.items()) {
ReturnInventoryCartonItem item = new ReturnInventoryCartonItem(); item.setId(IdWorker.getId()); item.setCartonId(box.getId());
item.setCustomerId(box.getCustomerId()); item.setSkuCode(line.skuCode()); item.setProductNameZh(names.get(line.skuCode())); item.setQuantity(line.quantity());
item.setPackingReservedQuantity(0L); items.insert(item);
}
box.setSkuTypes(input.items().size()); box.setTotalQuantity(input.items().stream().mapToLong(CountItem::quantity).sum());
box.setCountStatus("CONFIRMED"); box.setCountedAt(now); box.setCountedBy(identity.operatorKey()); box.setUpdatedAt(now); cartons.updateById(box);
}
for (String sku : expected.keySet()) {
long delta = newCounts.getOrDefault(sku, 0L) - oldCounts.getOrDefault(sku, 0L);
if (delta != 0 && balances.adjustCartonQuantity(receipt.getCustomerId(), boxes.get(0).getOverseasWarehouseId(), sku, delta) != 1) throw error(HttpStatus.CONFLICT, "SKU 箱数余额已变化,请重新核对");
}
if (sessions.release(identity.operatorKey(), receipt.getId()) != 1) throw error(HttpStatus.CONFLICT, "清点会话已变化,请重新核对");
Task result = task(receipt, boxes);
ReturnInventoryOperation operation = new ReturnInventoryOperation(); operation.setId(IdWorker.getId()); operation.setOperatorId(identity.operatorKey());
operation.setIdempotencyKey(request.idempotencyKey()); operation.setRequestHash(hash); operation.setOperationType("COUNT_CONTENTS");
operation.setResultCartonId(boxes.get(0).getId()); operation.setResultJson(json(result)); operation.setCreatedAt(now); operation.setUpdatedAt(now); operations.insert(operation);
return result;
}
private Task task(ReturnReceipt receipt, List<ReturnInventoryCarton> boxes) {
List<Sku> expected = receiptItems.listReceipt(receipt.getId()).stream().map(line -> new Sku(line.getSkuCode(), null, line.getReceivedQuantity())).toList();
List<Box> contents = boxes.stream().map(box -> new Box(box.getCartonMark(), items.listCarton(box.getId()).stream()
.map(line -> new Sku(line.getSkuCode(), line.getProductNameZh(), "PENDING".equals(box.getCountStatus()) ? 0 : line.getQuantity())).toList())).toList();
return new Task(receipt.getReceiptNo(), boxes.isEmpty() ? null : boxes.get(0).getSourceReturnNo(), receipt.getPalletCode(),
boxes.stream().allMatch(box -> "CONFIRMED".equals(box.getCountStatus())) ? "CONFIRMED" : sessions.findReceipt(receipt.getId()) != null ? "COUNTING" : "PENDING", expected, contents);
}
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch(Exception error) { throw new IllegalStateException(error); } }
private String payload(Object value) {
try { return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(java.nio.charset.StandardCharsets.UTF_8))); }
catch(Exception error) { throw new IllegalStateException(error); }
}
private ResponseStatusException error(HttpStatus status, String message) { return new ResponseStatusException(status, message); }
}
@@ -142,6 +142,7 @@ public class ReturnInventoryService {
|| !first.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId())
|| !AVAILABLE.equals(carton.getStatus())
|| !sourceMatches(carton, returnNos)) throw conflict("来源箱不属于所选退货单或已被占用");
ReturnCartonCountGuard.requireConfirmed(carton);
if ("BOX".equals(mode) && "PARCEL".equals(carton.getSourceUnitType())) throw badRequest("包裹散 SKU 不能作为整箱选择");
for (ReturnInventoryCartonItem item : cartonItems.lockCarton(carton.getId())) {
long free = Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity()));
@@ -385,6 +386,7 @@ public class ReturnInventoryService {
if (carton == null || !customerId.equals(carton.getCustomerId())
|| !warehouseId.equals(carton.getOverseasWarehouseId()) || !matchesReturnSource(carton, returnNo)
|| !"OUTBOUND_PENDING".equals(carton.getStatus())) throw conflict("退货箱状态或客户、仓库、来源不匹配");
ReturnCartonCountGuard.requireConfirmed(carton);
List<ReturnInventoryCartonItem> contents = cartonItems.lockCarton(carton.getId());
long total = 0;
var skus = new java.util.HashSet<String>();
@@ -449,6 +451,7 @@ public class ReturnInventoryService {
carton.setSourceReturnNo(order.getReturnNo()); carton.setCartonMark(cartonMark);
carton.setPalletCode(text(receipt.getPalletCode()));
carton.setSourceUnitType(receipt.getUnitType());
carton.setCountStatus("PALLET".equals(receipt.getUnitType()) ? "PENDING" : "CONFIRMED");
carton.setSourceUnitCode(normalizedUnitCode);
// Pallet measurements describe the carrier, never each child carton.
if ("BOX".equals(receipt.getUnitType())) {
@@ -464,6 +467,15 @@ public class ReturnInventoryService {
balances.adjust(IdWorker.getId(), order.getCustomerId(), order.getOverseasWarehouseId(), line.skuCode(),
text(line.productNameZh()), line.quantity(), line.quantity(), 1);
}
if ("BOX".equals(receipt.getUnitType())) {
var contents = lines.stream().map(line -> new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Sku(line.skuCode(), line.productNameZh(), line.quantity())).toList();
var snapshot = new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Task(receipt.getReceiptNo(), order.getReturnNo(), receipt.getPalletCode(), "CONFIRMED", contents,
List.of(new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Box(cartonMark, contents)));
ReturnInventoryOperation operation = new ReturnInventoryOperation(); operation.setId(IdWorker.getId());
operation.setOperatorId(receipt.getOperatorId()); operation.setIdempotencyKey("sign-contents-" + receipt.getId() + "-" + cartonId);
operation.setRequestHash(hash(snapshot)); operation.setOperationType("SIGN_CONTENTS"); operation.setResultCartonId(cartonId);
operation.setResultJson(json(snapshot)); operation.setCreatedAt(now); operation.setUpdatedAt(now); operations.insert(operation);
}
return carton;
}
@@ -512,7 +524,7 @@ public class ReturnInventoryService {
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.getSourceBatchNo(), carton.getSourceReturnNo(), displayStatus(carton),
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());
@@ -557,7 +569,7 @@ public class ReturnInventoryService {
List<ReturnInventoryResponses.PlatformCarton> records = total == 0 ? List.of() : cartons.platformPage(customerId, warehouseId, cartonMark,
returnNo, status, sku, (request.page() - 1) * request.size(), request.size()).stream().map(carton -> new ReturnInventoryResponses.PlatformCarton(
carton.getCustomerId(), carton.getOverseasWarehouseId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getSourceUnitType(), carton.getSourceBatchNo(),
carton.getStatus(), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), carton.getRelabelStatus(), carton.getLength(),
displayStatus(carton), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), carton.getRelabelStatus(), carton.getLength(),
carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt())).toList();
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@@ -616,6 +628,21 @@ public class ReturnInventoryService {
}
/** Print only persisted physical cartons; a parcel's SKU rows are not cartons. */
@Transactional(readOnly = true)
public List<ReturnOrderResponses.ReceiptCarton> receiptContents(ReturnReceipt receipt) {
if ("PARCEL".equals(receipt.getUnitType())) return List.of();
var snapshot = operations.receiptContents(receipt.getCustomerId(), receipt.getId());
if (snapshot != null) {
var task = read(snapshot.getResultJson(), new TypeReference<com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Task>() { });
if (!receipt.getReceiptNo().equals(task.receiptNo())) throw new IllegalStateException("箱内清点快照与签收单不一致");
return task.boxes().stream().map(box -> new ReturnOrderResponses.ReceiptCarton(receipt.getReceiptNo(), receipt.getPalletCode(), box.cartonMark(), "CONFIRMED", snapshot.getCreatedAt(),
box.items().stream().map(line -> new ReturnOrderResponses.ReceiptItem(line.skuCode(), line.productNameZh(), line.quantity(), 1)).toList())).toList();
}
// Never present provisional averages or subsequently changed inventory as receipt facts.
return cartons.listReceipt(receipt.getId()).stream().map(box -> new ReturnOrderResponses.ReceiptCarton(receipt.getReceiptNo(), receipt.getPalletCode(), box.getCartonMark(),
"PENDING".equals(box.getCountStatus()) ? "PENDING" : "UNRECORDED", box.getCountedAt(), List.of())).toList();
}
@Transactional(readOnly = true)
public List<ReturnOrderResponses.CartonLabel> receiptLabels(ReturnOrder order, ReturnReceipt receipt,
List<ReturnOrderLine> lines) {
@@ -780,6 +807,7 @@ public class ReturnInventoryService {
ReturnInventoryCarton carton = cartons.findMark(required(mark, "箱唛不能为空"));
if (carton == null || !String.valueOf(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw notFound("当前仓没有该退货箱");
if (!AVAILABLE.equals(carton.getStatus()) || "PARCEL".equals(carton.getSourceUnitType())) throw conflict("该箱不是可用实物箱");
ReturnCartonCountGuard.requireConfirmed(carton);
return cartonDetail(carton);
}
@@ -801,6 +829,7 @@ public class ReturnInventoryService {
if (carton == null || !AVAILABLE.equals(carton.getStatus()) || "PARCEL".equals(carton.getSourceUnitType())
|| !String.valueOf(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw conflict("来源箱不存在或不可合并");
if (customer != null && !customer.equals(carton.getCustomerId())) throw conflict("不同客户的箱不能合并");
ReturnCartonCountGuard.requireConfirmed(carton);
customer = carton.getCustomerId(); returnNos.addAll(java.util.Arrays.asList(required(carton.getSourceReturnNo(), "来源退货单缺失").split(",")));
Map<String, Long> items = new LinkedHashMap<>();
for (var item : cartonItems.lockCarton(carton.getId())) {
@@ -865,6 +894,7 @@ public class ReturnInventoryService {
|| limits.containsKey(targetMark))) throw conflict("目标箱不存在、被占用、跨客户或与来源箱相同");
if (existingTarget != null && cartonItems.lockCarton(existingTarget.getId()).stream().anyMatch(i -> value(i.getPackingReservedQuantity()) > 0))
throw conflict("目标箱已有合箱预占,不能并入");
if (existingTarget != null) ReturnCartonCountGuard.requireConfirmed(existingTarget);
if (existingTarget != null) for (var item : cartonItems.lockCarton(existingTarget.getId())) {
long tracked = packingTrace.ownership(customerId, targetMark, item.getSkuCode()).stream()
.mapToLong(com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership::quantity).sum();
@@ -894,6 +924,7 @@ public class ReturnInventoryService {
&& String.valueOf(identity.overseasWarehouseId()).equals(locked.getOverseasWarehouseId())
&& sourceMatches(locked, returnNos) && (limits != null || "PARCEL".equals(locked.getSourceUnitType()))) {
sources.add(locked); sourceOwners.put(locked.getId(), candidate.getValue());
ReturnCartonCountGuard.requireConfirmed(locked);
}
}
if (sources.isEmpty()) throw conflict("所选退货单没有可合箱的库存箱");
@@ -1031,7 +1062,7 @@ public class ReturnInventoryService {
.map(item -> new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), item.getQuantity(),
Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())))).toList();
return new ReturnInventoryResponses.Label(carton.getCartonMark(), carton.getOverseasWarehouseId(), carton.getCustomerId(),
carton.getSourceBatchNo(), carton.getSourceReturnNo(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), items, LocalDateTime.now(), printableHtml(carton, items));
carton.getSourceBatchNo(), carton.getSourceReturnNo(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), items, LocalDateTime.now(), printableHtml(carton));
}
private ReturnInventoryResponses.InventoryItem item(ReturnInventoryRow row) {
@@ -1045,26 +1076,24 @@ public class ReturnInventoryService {
value(balance.getTotalQuantity()), value(balance.getAvailableQuantity()), value(balance.getCartonQuantity()), balance.getUpdatedAt());
}
private static String displayStatus(ReturnInventoryCarton carton) {
return "PENDING".equals(carton.getCountStatus()) && AVAILABLE.equals(carton.getStatus()) ? "PENDING_COUNT" : carton.getStatus();
}
private ReturnInventoryResponses.CartonDetail cartonDetail(ReturnInventoryCarton carton) {
List<ReturnInventoryResponses.CartonItem> items = cartonItems.listCarton(carton.getId()).stream()
.map(item -> new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), item.getQuantity(),
"AVAILABLE".equals(carton.getStatus())
"AVAILABLE".equals(carton.getStatus()) && "CONFIRMED".equals(carton.getCountStatus())
? Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())) : 0)).toList();
return new ReturnInventoryResponses.CartonDetail(carton.getCartonMark(), carton.getCustomerId(), carton.getOverseasWarehouseId(), carton.getSourceBatchNo(), carton.getSourceReturnNo(),
carton.getStatus(), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), carton.getRelabelStatus(), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), items, carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt(), packingTrace.listCartonHistory(carton.getCustomerId(), carton.getCartonMark()));
displayStatus(carton), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), carton.getRelabelStatus(), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), items, carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt(), packingTrace.listCartonHistory(carton.getCustomerId(), carton.getCartonMark()));
}
private static String printableHtml(ReturnInventoryCarton carton, List<ReturnInventoryResponses.CartonItem> items) {
StringBuilder rows = new StringBuilder();
for (ReturnInventoryResponses.CartonItem item : items) {
rows.append("<div class=\"line\"><span class=\"sku\">")
.append(escape(item.skuCode()))
.append("</span></div>");
}
private static String printableHtml(ReturnInventoryCarton carton) {
return "<!doctype html><html><head><meta charset=\"utf-8\"><title>退货箱唛</title><style>"
+ "@page{size:100mm 100mm;margin:8mm}*{box-sizing:border-box}body{font-family:Arial,'Microsoft YaHei',sans-serif;color:#111;margin:0}.label{width:84mm;height:84mm;padding:4mm;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;page-break-after:always}.label:last-child{page-break-after:auto}.mark{font:700 14px Arial,sans-serif;line-height:1.25;overflow-wrap:anywhere;margin:0 0 5mm}.barcode{width:100%;max-width:76mm;margin:0 0 4mm}.barcode svg{display:block;width:100%;height:auto}.line{display:flex;justify-content:center;align-items:baseline;width:100%;padding:1mm 0;font-size:12px}.sku{max-width:76mm;overflow-wrap:anywhere;font-weight:700}</style></head><body><div class=\"label\"><div class=\"mark\">"
+ "@page{size:100mm 100mm;margin:8mm}*{box-sizing:border-box}body{font-family:Arial,'Microsoft YaHei',sans-serif;color:#111;margin:0}.label{width:84mm;height:84mm;padding:4mm;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;page-break-after:always}.label:last-child{page-break-after:auto}.mark{font:700 14px Arial,sans-serif;line-height:1.25;overflow-wrap:anywhere;margin:0 0 5mm}.barcode{width:100%;max-width:76mm;margin:0}.barcode svg{display:block;width:100%;height:auto}</style></head><body><div class=\"label\"><div class=\"mark\">"
+ escape(carton.getCartonMark()) + "</div><div class=\"barcode\">"
+ ReturnCartonBarcode.svg(carton.getCartonMark()) + "</div>" + rows
+ ReturnCartonBarcode.svg(carton.getCartonMark()) + "</div>"
+ "</div></body></html>";
}
private static String dimension(BigDecimal value) { return value == null ? "—" : value.stripTrailingZeros().toPlainString(); }
@@ -124,7 +124,7 @@ public class ReturnReceiptService {
private void createTailOrder(ReturnOrder returnOrder, String operatorId, LocalDateTime now) {
LastlegOrder tail = new LastlegOrder();
tail.setId(IdWorker.getId());
tail.setOrderNo(numbers.next("RETURN_RELABEL", returnOrder.getOverseasWarehouseId(), tail.getId()));
tail.setOrderNo(returnOrder.getReturnNo());
tail.setCustomerId(returnOrder.getCustomerId()); tail.setOrderType("RETURN_RELABEL");
tail.setSourceRegion("CUSTOMER"); tail.setSourceInboundId(returnOrder.getReturnNo());
tail.setSourceOrderId(String.valueOf(returnOrder.getId()));
@@ -301,7 +301,8 @@ public class ReturnReceiptService {
List<ReturnOrderResponses.Photo> photos = photoList(order).stream()
.map(file -> new ReturnOrderResponses.Photo(file.fileId(), file.fileName(), file.contentType(), file.sizeBytes())).toList();
return new ReturnOrderResponses.Detail(summary(order, relabelSubOrders(List.of(order)).getOrDefault(order.getLastlegOrderId(), List.of())), order.getRemark(), items, receiptViews, palletViews, photos,
inventory.packingHistory(order.getCustomerId(), order.getReturnNo()));
inventory.packingHistory(order.getCustomerId(), order.getReturnNo()),
orderReceipts.stream().flatMap(receipt -> inventory.receiptContents(receipt).stream()).toList());
}
private String combineLabels(List<ReturnOrderResponses.CartonLabel> labels) {
@@ -364,7 +365,7 @@ public class ReturnReceiptService {
List<ReturnOrderResponses.RelabelCandidate> result = new ArrayList<>();
for (ReturnReceipt receipt : receipts.listOrder(order.getId())) {
for (ReturnInventoryCarton carton : cartons.listReceipt(receipt.getId())) {
if (!ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) continue;
if (!ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || !"CONFIRMED".equals(carton.getCountStatus()) || carton.getRelabelApplicationId() != null) continue;
List<ReturnOrderResponses.Item> items = cartonItems.listCarton(carton.getId()).stream()
.map(i -> new ReturnOrderResponses.Item(i.getSkuCode(), i.getProductNameZh(), value(i.getQuantity()), value(i.getQuantity()))).toList();
result.add(new ReturnOrderResponses.RelabelCandidate(carton.getId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getStatus(), value(carton.getTotalQuantity()), items, carton.getRelabelStatus()));
@@ -457,6 +458,7 @@ public class ReturnReceiptService {
if (cartonItems.lockCarton(carton.getId()).stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0))
throw conflict("退货 SKU 已被合箱计划锁定,请先完成或取消合箱计划");
long allocated = Math.min(remaining, skuQuantity);
ReturnCartonCountGuard.requireConfirmed(carton);
RelabelItem item = new RelabelItem(); item.setId(IdWorker.getId()); item.setApplicationId(appId); item.setCustomerId(customerId);
item.setSkuCode(source.getSkuCode());
item.setProductNameZh(text(source.getProductNameZh()) == null ? source.getSkuCode() : text(source.getProductNameZh()));
@@ -72,6 +72,7 @@ public class LastlegOrderService {
"OVERSEAS_CUSTOMS_COMPLETED", "ACTUAL_ARRIVAL_WAITING_UNLOAD", "PICKUP_APPOINTED", "UNLOADED");
@Resource private LastlegOrderMapper orderMapper;
@Resource private com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService returnReceipts;
@Resource private LastlegOrderDetailMapper detailMapper;
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
@Resource private OverseasFeeSourceService feeSources;
@@ -366,7 +367,9 @@ public class LastlegOrderService {
.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(),
value.getOperatorName(), value.getResult(), value.getCreatedAt())).toList());
value.getOperatorName(), value.getResult(), value.getCreatedAt())).toList(),
"RETURN_RELABEL".equals(order.getOrderType()) && StringUtils.hasText(order.getSourceOrderNo())
? returnReceipts.detail(order.getCustomerId(), order.getSourceOrderNo()) : null);
}
@Transactional
@@ -671,7 +674,7 @@ public class LastlegOrderService {
}
static String displayOrderNo(LastlegOrder order) {
return "WAREHOUSE_TRANSFER".equals(order.getOrderType()) && StringUtils.hasText(order.getSourceOrderNo())
return ("WAREHOUSE_TRANSFER".equals(order.getOrderType()) || "RETURN_RELABEL".equals(order.getOrderType())) && StringUtils.hasText(order.getSourceOrderNo())
? order.getSourceOrderNo() : order.getOrderNo();
}
@@ -35,7 +35,7 @@ public final class WarehouseTransferResponses {
String lastlegOrderNo, String operatingWarehouseId, String fbaWarehouseCode,
String destinationType, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, String deliveryMethod, boolean needRelabel, String relabelNo,
String relabelApplicationStatus, String relabelFulfillmentStatus,
String relabelStatus, String relabelFulfillmentStatus,
String status, LocalDateTime estimatedArrivalAt, LocalDateTime createdAt) {}
public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode,
Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus,