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
@@ -3587,3 +3587,45 @@ CREATE TABLE event_delivery_attempt (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 159 AS initialized_tables;
-- 退货托盘签收箱内清点;MySQL 8;仅在目标尾程库执行,可重复运行。
USE baiyue_lastleg;
SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='count_status'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN count_status VARCHAR(16) NOT NULL DEFAULT ''CONFIRMED'' COMMENT ''箱内清点状态 PENDING待清点 CONFIRMED已确认''');
PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt;
SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='counted_at'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点确认时间''');
PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt;
SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='counted_by'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点确认操作人''');
PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt;
SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND index_name='idx_ll_return_count'),'SELECT 1','CREATE INDEX idx_ll_return_count ON ll_return_inventory_carton(overseas_warehouse_id,count_status,status,source_receipt_id)');
PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt;
-- 历史箱:只迁移整批仍可用、没有换标/合箱占用、未确认清点的托盘签收箱。
-- 已进入履约或合箱的批次不回退;后续需要逐批人工核实。
UPDATE ll_return_inventory_carton c
JOIN (SELECT source_receipt_id FROM (SELECT a.source_receipt_id
FROM ll_return_inventory_carton a WHERE a.source_receipt_id IS NOT NULL AND a.source_unit_type='PALLET'
GROUP BY a.source_receipt_id
HAVING SUM(a.status<>'AVAILABLE' OR a.relabel_application_id IS NOT NULL OR a.relabel_no IS NOT NULL OR a.counted_at IS NOT NULL)=0
AND SUM(EXISTS(SELECT 1 FROM ll_return_inventory_carton_item i WHERE i.carton_id=a.id AND COALESCE(i.packing_reserved_quantity,0)>0))=0
AND SUM(EXISTS(SELECT 1 FROM ll_return_packing_trace t WHERE t.source_carton_mark=a.carton_mark OR t.target_carton_mark=a.carton_mark))=0
) pending) p ON p.source_receipt_id=c.source_receipt_id
SET c.count_status='PENDING' WHERE c.source_unit_type='PALLET' AND c.counted_at IS NULL;
-- 退货箱内清点作业会话:每位操作员只能认领一个批次,每个批次只能被一人认领。
CREATE TABLE IF NOT EXISTS ll_return_carton_count_session (
operator_id VARCHAR(128) NOT NULL COMMENT '平台PDA操作员标识',
warehouse_id VARCHAR(128) NOT NULL COMMENT '认领时绑定海外仓标识',
receipt_id BIGINT NULL COMMENT '正在清点的签收批次,完成后释放为NULL',
started_at DATETIME(3) NULL COMMENT '本次开始清点时间',
created_at DATETIME(3) NOT NULL COMMENT '会话创建时间',
updated_at DATETIME(3) NOT NULL COMMENT '会话更新时间',
PRIMARY KEY(operator_id),
UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id)
) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话';
-- 退货尾程与来源退货单统一业务单号,保留ID及库存/履约状态。
USE baiyue_lastleg;
UPDATE ll_order o
JOIN ll_return_order r ON r.lastleg_order_id=o.id AND r.customer_id=o.customer_id
SET o.order_no=r.return_no, r.lastleg_order_no=r.return_no
WHERE o.order_type='RETURN_RELABEL' AND o.source_order_no=r.return_no
AND (NOT (o.order_no <=> r.return_no) OR NOT (r.lastleg_order_no <=> r.return_no));
@@ -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,
@@ -3160,3 +3160,37 @@ SET @return_outbound_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WH
PREPARE return_outbound_stmt FROM @return_outbound_ddl;
EXECUTE return_outbound_stmt;
DEALLOCATE PREPARE return_outbound_stmt;
-- 退货托盘签收箱内清点;MySQL 8;仅在目标尾程库执行,可重复运行。
USE baiyue_lastleg;
SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='count_status'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN count_status VARCHAR(16) NOT NULL DEFAULT ''CONFIRMED'' COMMENT ''箱内清点状态 PENDING待清点 CONFIRMED已确认''');
PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt;
SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='counted_at'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点确认时间''');
PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt;
SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='counted_by'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点确认操作人''');
PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt;
SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND index_name='idx_ll_return_count'),'SELECT 1','CREATE INDEX idx_ll_return_count ON ll_return_inventory_carton(overseas_warehouse_id,count_status,status,source_receipt_id)');
PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt;
-- 历史箱:只迁移整批仍可用、没有换标/合箱占用、未确认清点的托盘签收箱。
-- 已进入履约或合箱的批次不回退;后续需要逐批人工核实。
UPDATE ll_return_inventory_carton c
JOIN (SELECT source_receipt_id FROM (SELECT a.source_receipt_id
FROM ll_return_inventory_carton a WHERE a.source_receipt_id IS NOT NULL AND a.source_unit_type='PALLET'
GROUP BY a.source_receipt_id
HAVING SUM(a.status<>'AVAILABLE' OR a.relabel_application_id IS NOT NULL OR a.relabel_no IS NOT NULL OR a.counted_at IS NOT NULL)=0
AND SUM(EXISTS(SELECT 1 FROM ll_return_inventory_carton_item i WHERE i.carton_id=a.id AND COALESCE(i.packing_reserved_quantity,0)>0))=0
AND SUM(EXISTS(SELECT 1 FROM ll_return_packing_trace t WHERE t.source_carton_mark=a.carton_mark OR t.target_carton_mark=a.carton_mark))=0
) pending) p ON p.source_receipt_id=c.source_receipt_id
SET c.count_status='PENDING' WHERE c.source_unit_type='PALLET' AND c.counted_at IS NULL;
-- 退货箱内清点作业会话:每位操作员只能认领一个批次,每个批次只能被一人认领。
CREATE TABLE IF NOT EXISTS ll_return_carton_count_session (
operator_id VARCHAR(128) NOT NULL COMMENT '平台PDA操作员标识',
warehouse_id VARCHAR(128) NOT NULL COMMENT '认领时绑定海外仓标识',
receipt_id BIGINT NULL COMMENT '正在清点的签收批次,完成后释放为NULL',
started_at DATETIME(3) NULL COMMENT '本次开始清点时间',
created_at DATETIME(3) NOT NULL COMMENT '会话创建时间',
updated_at DATETIME(3) NOT NULL COMMENT '会话更新时间',
PRIMARY KEY(operator_id),
UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id)
) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话';
@@ -236,13 +236,14 @@ paths:
/api/platform/v1/last-leg/orders/{id}:
get:
summary: 查询尾程订单详情
x-return-order-number: RETURN_RELABEL 类型的 order.orderNo 与来源退货单 returnNo 相同;创建时使用同一 RT 单号,退货关联快照 lastlegOrderNo 同步保存该号。
description: 权限 platform:overseas-warehouse:last-mile-order:list;返回订单、货物、状态历史、费用明细和操作记录。订单中的 containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁;grossWeightKg/netWeightKg 由头程冻结货物明细聚合。businessSource 返回 businessType、label、businessOrderNo、externalBookingNo;商业类型优先取同客户同尾程单的池来源桥,独立散板取自身来源单号,普通转运不推断为散板。orderType 保留履约类型;无关联单号为空。
security: [{ bearerAuth: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
responses:
'200': { description: 统一 ApiResponse 包装的尾程订单详情;order.relabelApplications 返回所有换标子单的独立状态和本次数量 }
'200': { description: 统一 ApiResponse 包装的尾程订单详情;order.relabelApplications 返回所有换标子单的独立状态和本次数量。RETURN_RELABEL 订单另返回 returnReceipt,其结构与退货单详情相同,按订单真实客户与 sourceOrderNo 读取签收汇总和 cartons 箱内签收清点快照;其他类型 returnReceipt 为 null }
'404': { description: 尾程订单不存在或不属于查询客户 }
/api/platform/v1/last-leg/orders/{id}/relabel-review:
put:
@@ -1617,7 +1618,7 @@ paths:
'200': { description: 客户换标申请分页, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantPageResponse' } } } }
post:
summary: 创建客户换标申请
description: 权限 merchant:overseas-warehouse:relabel:create;申请创建即冻结对应库存。SKU_LABEL 不要求派送方式;CARTON_LABEL/CARTON_AND_SKU 必须指定 TRUCK、PICKUP 或 EXPRESS。
description: 权限 merchant:overseas-warehouse:relabel:create;申请创建即冻结对应库存。一张申请只能选择一种换标类型,所有 items.operationType 必须一致;不同类型混合提交返回业务参数错误且不冻结库存。SKU_LABEL 不要求派送方式;CARTON_LABEL/CARTON_AND_SKU 必须指定 TRUCK、PICKUP 或 EXPRESS。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCreate' } } } }
responses:
@@ -1727,7 +1728,7 @@ paths:
/api/merchant/v1/last-leg/return-orders/{returnNo}:
get:
summary: 客户查询退货订单与签收批次
description: 权限 merchant:overseas-warehouse:return-order:list;返回各 SKU 的预报和实收数量,以及每次 PDA 签收产生的批次号和可选快递单号。
description: 权限 merchant:overseas-warehouse:return-order:list;返回各 SKU 的预报和实收数量,以及每次 PDA 签收产生的批次号和可选快递单号。cartons 数组包含 receiptNo、palletCode、cartonMark、countStatus、countedAt、items(skuCode、productNameZh、quantity、boxCount)。CONFIRMED 为签收或清点时不可变箱内快照;PENDING 的 items 为空,不返回预分配平均数;UNRECORDED 表示历史未留存箱内快照,不能以已变化库存冒充签收数量。包裹散件不生成实物箱明细。
security: [{ bearerAuth: [] }]
parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 当前客户退货订单详情 }, '404': { description: 当前客户下退货订单不存在 } }
@@ -0,0 +1,93 @@
openapi: 3.0.3
info:
title: 退货托盘箱内清点
version: 1.0.0
security: [{ bearerAuth: [] }]
paths:
/api/pda/v1/last-leg/return-inventory/counting/current:
get:
summary: 读取当前登录操作员未完成的清点任务
description: 作业会话由数据库保存,退出页面或刷新不释放;只返回本人且绑定仓一致的任务。无任务data为null。
responses:
'200': { description: COUNTING任务或null }
'409': { description: 操作员在其他仓仍有未完成任务或原任务异常 }
/api/pda/v1/last-leg/return-inventory/counting/receipts/{receiptNo}/begin:
post:
summary: 弹窗确认后开始或恢复清点任务
description: 按操作员会话行锁和签收批次行锁串行认领,数据库保证一人一任务且一任务一人。同一操作员重复认领当前任务自然幂等;本单状态COUNTING,所有箱仍不可换标或出库。开始另一单或认领他人任务409;退出不释放。
parameters: [{ name: receiptNo, in: path, required: true, schema: { type: string } }]
responses:
'200': { description: 返回COUNTING任务,允许填写真实数量 }
'404': { description: 任务不存在或不属于绑定仓 }
'409': { description: 已认领其他单、被其他人认领、无需清点或被其他作业占用 }
/api/pda/v1/last-leg/return-inventory/counting/scan/{code}:
get:
summary: 统一扫描箱号或托盘号读取待清点批次
description: 仅精确匹配登录绑定仓的箱号或托盘号,仅返回PENDING且AVAILABLE的托盘签收批次。一个托盘多次签收返回多个任务供选择。无待清点箱返回空数组,PDA显示无需清点;权限、网络或服务错误不得当作无需清点。
parameters: [{ name: code, in: path, required: true, schema: { type: string, maxLength: 128 } }]
responses:
'200': { description: data为清点任务数组,无待清点箱返回空数组 }
'400': { description: 扫码内容为空或超过128字符 }
'403': { description: PDA角色、权限或仓库绑定缺失 }
/api/pda/v1/last-leg/return-inventory/counting/tasks:
get:
summary: 分页查询绑定海外仓的待清点签收批次
description: 需要 lastleg:pda:operate 和 ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR。仓库来自登录身份;托盘签收按一次签收批次整体清点,待清点箱内 quantity=0,不展示平均分配为实际数量。
parameters:
- { name: page, in: query, required: true, schema: { type: integer, minimum: 1 } }
- { name: size, in: query, required: true, schema: { type: integer, minimum: 1, maximum: 50 } }
- { name: keyword, in: query, schema: { type: string, maxLength: 128 }, description: 退货单、托盘或箱号 }
responses:
'200': { description: code=200,data 为 records/page/size/total/pages 分页信封 }
'403': { description: PDA角色、权限或仓库绑定缺失 }
/api/pda/v1/last-leg/return-inventory/counting/cartons/{cartonMark}:
get:
summary: 扫描箱号读取所属托盘签收批次及全部箱子
parameters: [{ name: cartonMark, in: path, required: true, schema: { type: string } }]
responses:
'200': { description: data含receiptNo/returnNo/palletCode/status/expectedItems/boxes,各箱含cartonMark/items }
'404': { description: 箱号不存在或不属于绑定仓,不泄露跨仓数据 }
'409': { description: 非托盘签收实物箱 }
/api/pda/v1/last-leg/return-inventory/counting/receipts/{receiptNo}:
post:
summary: 提交整个托盘签收批次的真实箱内清点
description: 仅本人已认领的COUNTING任务允许提交。所有实物箱各一次,同箱SKU不得重复且数量为正整数;允许不等量和混箱。各箱SKU总量必须等于签收总量。锁定操作员会话、签收批次和各箱,事务内替换明细、整批放行并释放作业会话,不改变SKU总库存。失败不释放会话。未清点箱不得合箱、换标或创建及执行退货出库。幂等键按操作员隔离,同载荷重放确认快照,不同载荷409。错误保留requestId/traceId。
parameters: [{ name: receiptNo, in: path, required: true, schema: { type: string } }]
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/Confirm' }
responses:
'200': { description: CONFIRMED清点批次,箱明细为真实数量 }
'400': { description: 箱号或SKU重复、非本批次、数量无效 }
'404': { description: 批次不存在或不属于绑定仓 }
'409': { description: 汇总不符、已清点、已占用或幂等键载荷冲突,事务回滚 }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
schemas:
Confirm:
type: object
required: [idempotencyKey, boxes]
properties:
idempotencyKey: { type: string, minLength: 1, maxLength: 128 }
boxes:
type: array
minItems: 1
maxItems: 500
items:
type: object
required: [cartonMark, items]
properties:
cartonMark: { type: string, minLength: 1, maxLength: 128 }
items:
type: array
minItems: 1
maxItems: 100
items:
type: object
required: [skuCode, quantity]
properties:
skuCode: { type: string, minLength: 1, maxLength: 128 }
quantity: { type: integer, format: int64, minimum: 1 }
@@ -169,6 +169,18 @@ class DeliveryTransferServiceTest {
org.mockito.Mockito.lenient().when(returnStock.matchesReturnSource(any(), any())).thenCallRealMethod();
}
@Test
void uncountedPalletBoxCannotCreateReturnOutbound() {
ReturnInventoryCarton carton = returnCarton("RC-UNCOUNTED", "PAL-1");
carton.setCountStatus("PENDING");
when(returnCartons.lockMark("RC-UNCOUNTED")).thenReturn(carton);
var error = assertThrows(ResponseStatusException.class, () -> service.createReturnOutbound("TENANT-A", "U-1",
new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("RC-UNCOUNTED"), null, "uncounted-1")));
assertEquals(409, error.getStatusCode().value());
org.mockito.Mockito.verifyNoInteractions(returnCartonItems, outboundPallets);
verify(outboundOrders, org.mockito.Mockito.never()).insert(any(OutboundOrder.class));
}
@Test
void returnBoxOutboundCanSelectOneCartonFromAPallet() {
ReturnInventoryCarton first = returnCarton("RC-1", "PAL-1");
@@ -569,6 +569,38 @@ class RelabelApplicationServiceTest {
assertEquals("COMPLETED", order.getRelabelStatus());
}
@Test
void zeroFeeCompletionPersistsFulfillmentForBothOutboundRoutes() {
for (String route : List.of("PRE_DISPATCH_CREATED", "OUTBOUND_CREATED")) {
RelabelApplication app = application(11L, "FBR-ZERO", "10001", "key", "hash", RelabelApplicationService.READY_FOR_OUTBOUND);
app.setLastlegOrderId(21L);
app.setWalletStatus("NOT_REQUIRED");
app.setChargeAmount(BigDecimal.ZERO);
app.setFulfillmentStatus(route);
if ("OUTBOUND_CREATED".equals(route)) app.setOutboundOrderNo("OUT-ZERO");
LastlegOrder order = order(21L, "10001", "LMO-ZERO", "PROCESSING");
order.setOrderType("RETURN_RELABEL");
order.setRelabelOrderNo(app.getRelabelNo());
when(applications.lockNo("10001", app.getRelabelNo())).thenReturn(app);
when(orders.selectById(21L)).thenReturn(order);
stubDetail(app);
List<String> persistedFulfillment = new java.util.ArrayList<>();
org.mockito.Mockito.doAnswer(invocation -> {
RelabelApplication saved = invocation.getArgument(0);
persistedFulfillment.add(saved.getFulfillmentStatus());
return 1;
}).when(applications).updateById(any(RelabelApplication.class));
service.changeStatus("10001", app.getRelabelNo(), "operator-2",
new RelabelRequests.ChangeStatus("COMPLETED", "finished"));
assertEquals("OUTBOUNDED", persistedFulfillment.get(persistedFulfillment.size() - 1), route);
}
verify(feeSources, never()).publishApproved(any(), any(), any());
verify(walletCommands, never()).publish(any(), anyLong(), any(), any(), any(), any(), anyLong());
verify(inventoryCommands, never()).release(any(), any(), any());
}
@Test
void completingEarlierReturnRelabelDoesNotOverwriteLaterApplicationStatus() {
RelabelApplication application = application(11L, "FBR-FIRST", "10001", "key-1", "hash", RelabelApplicationService.READY_FOR_OUTBOUND);
@@ -895,6 +927,25 @@ class RelabelApplicationServiceTest {
return app;
}
@Test
void uncountedPalletBoxCannotCreateRelabelApplication() {
var cartonMapper = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
ReflectionTestUtils.setField(service, "returnCartons", cartonMapper);
var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton();
carton.setId(901L); carton.setCustomerId("10001"); carton.setOverseasWarehouseId("WH-1");
carton.setCartonMark("RETURN-BOX-1"); carton.setStatus("AVAILABLE"); carton.setSourceUnitType("PALLET"); carton.setCountStatus("PENDING");
when(cartonMapper.lockId(901L)).thenReturn(carton);
var detail = new RelabelRequests.Item(null, null, null, "SKU-A", null, null, null,
"Return goods", 1, 1, "RT-1", null, "NEW-A", null, null,
null, null, null, "RETURN-BOX-1", null, null, "SKU_LABEL",
List.of(new RelabelRequests.SkuItem("SKU-A", "NEW-A", 1, null)), 901L);
var request = new RelabelRequests.Create("RT-1", "WH-1", LocalDateTime.now().plusDays(1),
"Li", "13800000000", null, null, "return-uncounted", List.of(detail), null, false);
var error = assertThrows(ResponseStatusException.class, () -> service.create("10001", "operator-1", request));
assertTrue(error.getReason().contains("清点"));
verify(applications, never()).insert(any(RelabelApplication.class));
}
@Test
void returnSkuRelabelRejectsQuantityBeyondSelectedSourceBox() {
var cartonMapper = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
@@ -934,6 +985,19 @@ class RelabelApplicationServiceTest {
"SKU-NEW", "Label on carton", "file-1", "relabel.pdf", "https://files/relabel.pdf")));
}
@Test
void rejectsDifferentOperationTypesInOneApplicationBeforeStockIsHeld() {
var first = carton("A", "RC-A", "FBA-A", "ABE8");
var second = new RelabelRequests.Item(101L, 202L, 301L, "B", null, null, null,
"商品B", 1, 1, "SHP-2", null, "B-NEW", null, null,
null, null, null, "RC-B", null, null, "SKU_LABEL", List.of());
var request = new RelabelRequests.Create("CUSTOMER-1", "WH-1", LocalDateTime.now().plusDays(1),
"Li", "13800000000", new BigDecimal("20.00"), null, "mixed-types", List.of(first, second));
var error = assertThrows(ResponseStatusException.class, () -> service.create("10001", "operator-1", request));
assertEquals(400, error.getStatusCode().value());
assertTrue(error.getReason().contains("只能选择一种"));
}
private static RelabelRequests.Item carton(String sku, String cartonMark, String fbaCartonMark,
String destinationWarehouseId) {
long positionId = "ABE8".equals(destinationWarehouseId) ? 202L : 201L;
@@ -0,0 +1,168 @@
package com.baiyue.lastleg.returnreceipt.service;
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 org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
@ExtendWith(MockitoExtension.class)
class ReturnCartonCountServiceTest {
@Mock ReturnReceiptMapper receipts;
@Mock ReturnReceiptItemMapper receiptItems;
@Mock ReturnInventoryCartonMapper cartons;
@Mock ReturnInventoryCartonItemMapper items;
@Mock ReturnInventoryBalanceMapper balances;
@Mock ReturnInventoryOperationMapper operations;
@Mock ReturnCartonCountSessionMapper sessions;
ReturnCartonCountService service;
PdaWarehouseIdentity identity = new PdaWarehouseIdentity("op", "operator", 123L);
ReturnReceipt receipt;
ReturnInventoryCarton a, b;
@BeforeEach void setup() {
service = new ReturnCartonCountService();
ReflectionTestUtils.setField(service,"receipts",receipts);
ReflectionTestUtils.setField(service,"receiptItems",receiptItems);
ReflectionTestUtils.setField(service,"cartons",cartons);
ReflectionTestUtils.setField(service,"items",items);
ReflectionTestUtils.setField(service,"balances",balances);
ReflectionTestUtils.setField(service,"operations",operations);
ReflectionTestUtils.setField(service,"sessions",sessions);
lenient().when(sessions.lockOperator("op")).thenReturn(new ReturnCartonCountSessionMapper.Session("op","123",1L,null));
lenient().when(sessions.lockOperator("other")).thenReturn(new ReturnCartonCountSessionMapper.Session("other","999",null,null));
lenient().when(sessions.release("op",1L)).thenReturn(1);
ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper().findAndRegisterModules());
receipt = new ReturnReceipt(); receipt.setId(1L); receipt.setReceiptNo("RR-1"); receipt.setCustomerId("C1"); receipt.setPalletCode("PLT-1");
a = box(2L,"RC#2-1"); b = box(3L,"RC#2-2");
}
private ReturnInventoryCarton box(long id, String mark) {
var box = new ReturnInventoryCarton(); box.setId(id); box.setCartonMark(mark); box.setCustomerId("C1"); box.setOverseasWarehouseId("123");
box.setCountStatus("PENDING"); box.setStatus("AVAILABLE"); box.setSourceReceiptId(1L); box.setSourceUnitType("PALLET"); return box;
}
private ReturnReceiptItem expected(String sku, long qty) {
var item = new ReturnReceiptItem(); item.setSkuCode(sku); item.setReceivedQuantity(qty); return item;
}
private ReturnInventoryCartonItem provisional(long box, String sku, long qty) {
var item = new ReturnInventoryCartonItem(); item.setCartonId(box); item.setSkuCode(sku); item.setQuantity(qty); item.setPackingReservedQuantity(0L); return item;
}
private void prepare(List<ReturnReceiptItem> expected) {
when(receipts.lockCountReceipt("RR-1")).thenReturn(receipt);
when(cartons.lockReceipt(1L)).thenReturn(List.of(a,b));
when(receiptItems.listReceipt(1L)).thenReturn(expected);
}
private Confirm unequal(long first, long second) {
return new Confirm("retry", List.of(new CountBox(a.getCartonMark(),List.of(new CountItem("SKU1",first))),new CountBox(b.getCartonMark(),List.of(new CountItem("SKU1",second)))));
}
@Test void unequalBoxesAreConfirmedWithoutChangingSkuUnitBalanceAndRetryIsStable() {
prepare(List.of(expected("SKU1",10)));
when(items.lockCarton(2L)).thenReturn(List.of(provisional(2,"SKU1",5)));
when(items.lockCarton(3L)).thenReturn(List.of(provisional(3,"SKU1",5)));
when(items.listCarton(2L)).thenReturn(List.of(provisional(2,"SKU1",3)));
when(items.listCarton(3L)).thenReturn(List.of(provisional(3,"SKU1",7)));
var result = service.confirm(identity,"RR-1",unequal(3,7));
assertThat(result.status()).isEqualTo("CONFIRMED");
assertThat(a.getTotalQuantity()).isEqualTo(3); assertThat(b.getTotalQuantity()).isEqualTo(7);
assertThat(a.getCountedBy()).isEqualTo("op"); assertThat(a.getCountedAt()).isNotNull();
verify(items).deleteCarton(2L); verify(items).deleteCarton(3L); verify(items,times(2)).insert(any(ReturnInventoryCartonItem.class));
verifyNoInteractions(balances);
var audit = org.mockito.ArgumentCaptor.forClass(ReturnInventoryOperation.class); verify(operations).insert(audit.capture());
when(operations.lockKey("op","retry")).thenReturn(audit.getValue());
assertThat(service.confirm(identity,"RR-1",unequal(3,7))).isEqualTo(result);
assertThatThrownBy(() -> service.confirm(identity,"RR-1",unequal(4,6))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("幂等键");
verify(items,times(2)).deleteCarton(anyLong());
}
@Test void mixedBoxesAdjustOnlyContainingBoxCounts() {
prepare(List.of(expected("SKU1",6),expected("SKU2",4)));
when(items.lockCarton(2L)).thenReturn(List.of(provisional(2,"SKU1",6)));
when(items.lockCarton(3L)).thenReturn(List.of(provisional(3,"SKU2",4)));
when(balances.adjustCartonQuantity("C1","123","SKU1",1)).thenReturn(1);
when(balances.adjustCartonQuantity("C1","123","SKU2",1)).thenReturn(1);
var request = new Confirm("mixed",List.of(new CountBox(a.getCartonMark(),List.of(new CountItem("SKU1",2),new CountItem("SKU2",1))),new CountBox(b.getCartonMark(),List.of(new CountItem("SKU1",4),new CountItem("SKU2",3)))));
service.confirm(identity,"RR-1",request);
verify(balances).adjustCartonQuantity("C1","123","SKU1",1); verify(balances).adjustCartonQuantity("C1","123","SKU2",1);
verifyNoMoreInteractions(balances); assertThat(a.getSkuTypes()).isEqualTo(2);
}
@Test void totalsMismatchDoesNotDeleteOrReleaseAnyBox() {
prepare(List.of(expected("SKU1",10)));
assertThatThrownBy(() -> service.confirm(identity,"RR-1",unequal(3,6))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("汇总");
verify(items,never()).deleteCarton(anyLong()); verify(cartons,never()).updateById(any(ReturnInventoryCarton.class)); verifyNoInteractions(balances);
assertThat(a.getCountStatus()).isEqualTo("PENDING");
}
@Test void duplicateOrForeignBoxesCannotBeSubmitted() {
prepare(List.of(expected("SKU1",10)));
var request = new Confirm("dup",List.of(new CountBox(a.getCartonMark(),List.of(new CountItem("SKU1",5))),new CountBox(a.getCartonMark(),List.of(new CountItem("SKU1",5)))));
assertThatThrownBy(() -> service.confirm(identity,"RR-1",request)).isInstanceOf(ResponseStatusException.class).hasMessageContaining("重复");
verify(items,never()).deleteCarton(anyLong());
}
@Test void warehouseScopeCannotBeSuppliedByAnotherOperator() {
when(receipts.lockCountReceipt("RR-1")).thenReturn(receipt); when(cartons.lockReceipt(1L)).thenReturn(List.of(a,b));
assertThatThrownBy(() -> service.confirm(new PdaWarehouseIdentity("other","other",999),"RR-1",unequal(3,7))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("当前仓");
verifyNoInteractions(items,receiptItems,operations);
}
@Test void scanDoesNotExposeProvisionalAverageAsActualCount() {
when(cartons.findMark(a.getCartonMark())).thenReturn(a); when(receipts.selectById(1L)).thenReturn(receipt);
when(cartons.listReceipt(1L)).thenReturn(List.of(a,b)); when(receiptItems.listReceipt(1L)).thenReturn(List.of(expected("SKU1",10)));
when(items.listCarton(2L)).thenReturn(List.of(provisional(2,"SKU1",5))); when(items.listCarton(3L)).thenReturn(List.of(provisional(3,"SKU1",5)));
var task = service.scan(identity,a.getCartonMark());
assertThat(task.boxes()).allSatisfy(box -> assertThat(box.items()).allSatisfy(item -> assertThat(item.quantity()).isZero()));
assertThatThrownBy(() -> ReturnCartonCountGuard.requireConfirmed(a)).isInstanceOf(ResponseStatusException.class).hasMessageContaining("清点");
a.setCountStatus("CONFIRMED"); assertThatCode(() -> ReturnCartonCountGuard.requireConfirmed(a)).doesNotThrowAnyException();
}
@Test void unifiedScanUsesExactCodeAndWarehouseAndReturnsAllPendingPalletBatches() {
var second = new ReturnReceipt(); second.setId(4L); second.setReceiptNo("RR-2"); second.setPalletCode("PLT-1");
when(receipts.scanPendingContents("123","PLT-1")).thenReturn(List.of(receipt,second));
when(cartons.listReceipt(1L)).thenReturn(List.of(a,b));
var tasks = service.scanTasks(identity," PLT-1 ");
assertThat(tasks).extracting(Task::receiptNo).containsExactly("RR-1","RR-2");
verify(receipts).scanPendingContents("123","PLT-1");
verifyNoInteractions(operations,balances);
}
@Test void unifiedScanWithNoPendingBoxesReturnsEmptyInsteadOfCompletedContents() {
when(receipts.scanPendingContents("123","RC-DONE")).thenReturn(List.of());
assertThat(service.scanTasks(identity,"RC-DONE")).isEmpty();
verifyNoInteractions(cartons,items,receiptItems);
}
@Test void beginClaimsAndRetriesSameTaskWithoutClaimingAgain() {
when(sessions.lockOperator("op")).thenReturn(new ReturnCartonCountSessionMapper.Session("op","123",null,null),new ReturnCartonCountSessionMapper.Session("op","123",1L,null));
when(receipts.lockCountReceipt("RR-1")).thenReturn(receipt); when(cartons.lockReceipt(1L)).thenReturn(List.of(a,b));
when(sessions.claim("op","123",1L)).thenReturn(1);
when(sessions.findReceipt(1L)).thenReturn(null,new ReturnCartonCountSessionMapper.Session("op","123",1L,null));
service.begin(identity,"RR-1"); service.begin(identity,"RR-1");
verify(sessions,times(1)).claim("op","123",1L);
verify(sessions,never()).release(anyString(),anyLong());
}
@Test void activeTaskBlocksAnotherTaskEvenAfterLeavingThePage() {
var another = new ReturnReceipt(); another.setId(4L); another.setReceiptNo("RR-2");
when(receipts.lockCountReceipt("RR-2")).thenReturn(another);
assertThatThrownBy(() -> service.begin(identity,"RR-2")).isInstanceOf(ResponseStatusException.class).hasMessageContaining("一次只能清点一单");
verify(sessions,never()).claim(anyString(),anyString(),anyLong()); verifyNoInteractions(cartons);
}
@Test void anotherOperatorCannotTakeClaimedTask() {
when(sessions.lockOperator("op")).thenReturn(new ReturnCartonCountSessionMapper.Session("op","123",null,null));
when(receipts.lockCountReceipt("RR-1")).thenReturn(receipt); when(cartons.lockReceipt(1L)).thenReturn(List.of(a,b));
when(sessions.findReceipt(1L)).thenReturn(new ReturnCartonCountSessionMapper.Session("someone","123",1L,null));
assertThatThrownBy(() -> service.begin(identity,"RR-1")).isInstanceOf(ResponseStatusException.class).hasMessageContaining("其他人员");
verify(sessions,never()).claim(anyString(),anyString(),anyLong());
}
@Test void currentRestoresClaimedTaskAndDirectConfirmRequiresBegin() {
when(sessions.findOperator("op")).thenReturn(new ReturnCartonCountSessionMapper.Session("op","123",1L,null));
when(sessions.findReceipt(1L)).thenReturn(new ReturnCartonCountSessionMapper.Session("op","123",1L,null));
when(receipts.selectById(1L)).thenReturn(receipt); when(cartons.listReceipt(1L)).thenReturn(List.of(a,b));
assertThat(service.current(identity).status()).isEqualTo("COUNTING");
when(sessions.lockOperator("op")).thenReturn(new ReturnCartonCountSessionMapper.Session("op","123",null,null));
when(receipts.lockCountReceipt("RR-1")).thenReturn(receipt); when(cartons.lockReceipt(1L)).thenReturn(List.of(a,b));
assertThatThrownBy(() -> service.confirm(identity,"RR-1",unequal(3,7))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("先开始");
verify(items,never()).deleteCarton(anyLong());
}
}
@@ -84,6 +84,46 @@ class ReturnInventoryServiceTest {
verify(packingPlans).listPlatform(null, "PLAN-1", "C1", "W1", "SKU1", 20L, 20L);
}
@Test
void receiptDetailsUseCountSnapshotEvenAfterInventoryChanges() throws Exception {
var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setCustomerId("C1"); receipt.setReceiptNo("RR-1"); receipt.setUnitType("PALLET"); receipt.setPalletCode("PLT-1");
var snapshot = new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Task("RR-1", "RT-1", "PLT-1", "CONFIRMED", List.of(), List.of(
new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Box("RC#2-1", List.of(new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Sku("A", "商品A", 3))),
new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Box("RC#2-2", List.of(new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Sku("A", "商品A", 7)))));
var operation = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOperation();
operation.setResultJson(new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules().writeValueAsString(snapshot));
when(operations.receiptContents("C1", 21L)).thenReturn(operation);
var detail = service.receiptContents(receipt);
assertThat(detail).hasSize(2);
assertThat(detail.get(0).items().get(0).quantity()).isEqualTo(3);
assertThat(detail.get(1).items().get(0).quantity()).isEqualTo(7);
assertThat(detail.get(0).countStatus()).isEqualTo("CONFIRMED");
verifyNoInteractions(cartons, cartonItems);
}
@Test
void pendingReceiptDetailsDoNotExposeProvisionalAveragesAndParcelsHaveNoBoxes() {
var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setCustomerId("C1"); receipt.setReceiptNo("RR-1"); receipt.setUnitType("PALLET");
var box = new ReturnInventoryCarton(); box.setCartonMark("RC#2-1"); box.setCountStatus("PENDING"); box.setTotalQuantity(5L);
when(cartons.listReceipt(21L)).thenReturn(List.of(box));
var detail = service.receiptContents(receipt);
assertThat(detail.get(0).countStatus()).isEqualTo("PENDING");
assertThat(detail.get(0).items()).isEmpty();
receipt.setUnitType("PARCEL");
assertThat(service.receiptContents(receipt)).isEmpty();
verifyNoInteractions(cartonItems);
}
@Test
void boxSigningStoresActualContentsAsImmutableReceiptSnapshot() {
var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setReceiptNo("RR-BOX"); receipt.setUnitType("BOX"); receipt.setUnitCode("RC-BOX"); receipt.setOperatorId("operator-1");
service.createReceiptCarton(completedOrder(), receipt, "RC-BOX", List.of(new ReturnInventoryService.ReceiptLine("A", "商品A", 3), new ReturnInventoryService.ReceiptLine("B", "商品B", 7)));
var capture = ArgumentCaptor.forClass(com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOperation.class);
verify(operations).insert(capture.capture());
assertThat(capture.getValue().getOperationType()).isEqualTo("SIGN_CONTENTS");
assertThat(capture.getValue().getResultJson()).contains("RC-BOX", "RR-BOX", "商品A", "\"quantity\":3", "\"quantity\":7");
}
@Test
void cartonDetailsExposeImmutableMergeHistoryWithinCustomerScope() {
ReturnInventoryCarton carton = new ReturnInventoryCarton();
@@ -141,7 +181,8 @@ class ReturnInventoryServiceTest {
when(cartons.findCustomerMark(carton.getCustomerId(), carton.getCartonMark())).thenReturn(carton);
when(cartonItems.listCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L)));
String html = service.label(carton.getCustomerId(), carton.getCartonMark()).printableHtml();
assertThat(html).contains("<svg", carton.getCartonMark(), "SKU-A", "box-sizing:border-box");
assertThat(html).contains("<svg", carton.getCartonMark(), "box-sizing:border-box");
assertThat(html).doesNotContain("SKU-A", "class=\"sku\"");
assertThat(html).doesNotContain("RT-1", "RB-1,RB-2", "—");
assertThat(html).doesNotContain("repeating-linear-gradient", "null", carton.getCustomerId(), carton.getOverseasWarehouseId());
}
@@ -309,7 +350,7 @@ class ReturnInventoryServiceTest {
assertThat(result.totalQuantity()).isEqualTo(1L);
assertThat(result.label().items()).hasSize(1);
assertThat(result.label().items().get(0).quantity()).isEqualTo(1L);
assertThat(result.label().printableHtml()).contains("RC-WINNER", "SKU-A");
assertThat(result.label().printableHtml()).contains("RC-WINNER").doesNotContain("SKU-A");
verify(operations).findKey("operator-2", "concurrent-key");
verify(operations).lockKey("operator-2", "concurrent-key");
verify(cartons, never()).selectById(anyLong());
@@ -299,7 +299,6 @@ class ReturnReceiptServiceTest {
AtomicReference<ReturnOrder> saved = new AtomicReference<>();
when(orders.findKey("customer-1", "create-key")).thenReturn(null);
when(numbers.next(eq("RETURN"), eq("10"), anyLong())).thenReturn("RT-1");
when(numbers.next(eq("RETURN_RELABEL"), eq("10"), anyLong())).thenReturn("RETU-1");
when(orders.insert(any(ReturnOrder.class))).thenAnswer(invocation -> { saved.set(invocation.getArgument(0)); return 1; });
when(orders.findNo("customer-1", "RT-1")).thenAnswer(invocation -> saved.get());
when(orderItems.listOrder(anyLong())).thenReturn(List.of());
@@ -313,7 +312,9 @@ class ReturnReceiptServiceTest {
assertEquals("RT-1", tail.getValue().getSourceInboundId());
assertEquals("RETURN_ORDER:RT-1", tail.getValue().getSourceEventId());
assertEquals(saved.get().getRequestHash(), tail.getValue().getSourcePayloadHash());
assertEquals("RETU-1", result.order().lastlegOrderNo());
assertEquals("RT-1", tail.getValue().getOrderNo());
assertEquals("RT-1", result.order().lastlegOrderNo());
verify(numbers, never()).next(eq("RETURN_RELABEL"), anyString(), anyLong());
}
@Test
@@ -75,6 +75,7 @@ class LastlegOrderServiceTest {
@Mock WarehouseResourceGateway warehouseResourceGateway;
@Mock ContainerShippingGateMapper containerShippingGates;
@Mock ReturnOrderReferenceResolver returnOrderReferences;
@Mock com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService returnReceipts;
LastlegOrderService service;
@Test
@@ -87,6 +88,13 @@ class LastlegOrderServiceTest {
assertEquals("WT-WH-20260911-020225", LastlegOrderService.displayOrderNo(order));
}
@Test
void returnTailOrderUsesSourceReturnNumber() {
LastlegOrder order = new LastlegOrder(); order.setOrderType("RETURN_RELABEL");
order.setOrderNo("RETU-OLD"); order.setSourceOrderNo("RT-1");
assertEquals("RT-1", LastlegOrderService.displayOrderNo(order));
}
@Test
void exposesStableContainerBookingIdInOrderSummary() {
LastlegOrder order = new LastlegOrder();
@@ -236,6 +244,18 @@ class LastlegOrderServiceTest {
.noneMatch(component -> "eventId".equals(component.getName())));
}
@Test
void returnTailDetailReadsReceiptFactsWithOwnedCustomerAndSourceReturnNumber() {
ReflectionTestUtils.setField(service, "returnReceipts", returnReceipts);
LastlegOrder order = order(11L, "100", "INBOUND_COMPLETED", null);
order.setOrderType("RETURN_RELABEL"); order.setSourceOrderNo("RT-SOURCE");
when(orders.selectOne(any())).thenReturn(order);
var receiptDetail = new com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses.Detail(null, null, List.of(), List.of(), List.of(), List.of());
when(returnReceipts.detail("100", "RT-SOURCE")).thenReturn(receiptDetail);
assertSame(receiptDetail, service.detail("100", 11L).returnReceipt());
verify(returnReceipts).detail("100", "RT-SOURCE");
}
@Test
void detailReadsContainerNodeFromTheContainerShippingGate() {
LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null);
@@ -136,7 +136,7 @@ class WarehouseTransferServiceTest {
1, 20, null, null, null, null, null, null, null, null));
assertEquals("RL-20260923-000001", result.records().get(0).relabelNo());
assertEquals("COMPLETED", result.records().get(0).relabelApplicationStatus());
assertEquals("COMPLETED", result.records().get(0).relabelStatus());
assertEquals("READY_FOR_DISPATCH", result.records().get(0).relabelFulfillmentStatus());
}
@@ -152,7 +152,7 @@ class WarehouseTransferServiceTest {
1, 20, null, null, null, null, null, null, null, null));
assertEquals(1, result.records().size());
assertNull(result.records().get(0).relabelApplicationStatus());
assertNull(result.records().get(0).relabelStatus());
assertNull(result.records().get(0).relabelFulfillmentStatus());
verify(relabelApplications, never()).selectList(any());
}
+8
View File
@@ -1,5 +1,13 @@
# 后端脚本管理与全量 HTTP 验收索引
`db/local-four-flows-schema-incremental-20261009.sql`:本地退货换标、仓储转运、贴标出库及退货出库结构比对所得的 MySQL 8 定向增量。显式 `USE baiyue_lastleg`,补齐预约结束时间/版本、转运换标来源绑定、分配 SKU 快照及转运收货幂等命令表;字段按存在性判断,表使用 `CREATE TABLE IF NOT EXISTS`,可重复执行,不删除或回填业务数据。使用操作者显式选择的本地数据库连接,无内置凭据;完整执行输出 `LOCAL_FLOW_SCHEMA_READY`。失败保留已完成的 DDL 与原错误,核对后重跑;不自动清理业务记录,不是全库迁移或生产验收入口。
`db/return-tail-order-number-incremental-20261009.sql` 将已验证关联的退货尾程单号及退货单尾程号快照统一为退货单号。显式 `USE baiyue_lastleg`,可重复执行,只更新同客户、关联订单 ID 和来源退货号均一致的 `RETURN_RELABEL` 单据;不改变业务 ID、库存或履约状态。执行前核对目标数据库,执行后核验查询应为空,异常关系保留供核实。已同步海外业务统一增量脚本。
`db/return-carton-count-session-incremental-20261009.sql` 为本地清点作业认领增量,明确指向 `baiyue_lastleg`,MySQL 8 可重复执行。新增按操作员唯一、签收批次唯一的持久会话表;开始清点后退出页面不释放,只有核平完成事务才释放,不修改库存数量。凭据取本机配置,执行输出作为迁移记录;失败保留错误,修复后重跑,不删除单据或清点会话。同表已同步尾程基线、海外统一增量及完整箱内清点增量脚本。
`db/return-carton-counting-incremental-20261009.sql` 为本地退货箱内清点增量,显式指向 `baiyue_lastleg`,适用于已执行退货合箱增量的 MySQL 8。增加清点状态、时间、操作人及索引;将整批仍可用且没有合箱追溯或换标占用的历史托盘签收箱转为待清点,不修改其数量。已履约或曾合箱的历史批次不自动回退,须单独核实。凭据由本机配置提供,不在脚本中保存;SQL 执行输出即为核验记录,可重复运行,已清点批次不会回退,失败保留错误和业务现场,不删除单据。同批结构同步到尾程基线与海外统一增量;服务须加载新代码后才能执行清点接口。
`db/return-relabel-truck-incremental-20261008.sql` 为本地退货换标排车增量,显式指向 `baiyue_iam` / `baiyue_lastleg`,适用于 MySQL 8。补齐管理员正式司机、车次与审核权限,以及车辆容量和正式车队表;不修改退货库存或业务单据。凭据从本机配置读取,可重复执行,失败保留原错误和业务现场;重新登录后核对权限快照。结构同步到基线及两个统一增量,权限由正式 IAM 权限种子维护。
`db/return-packing-incremental-20261008.sql` 为本地退货合箱增量,明确指向 `baiyue_iam` 和 `baiyue_lastleg`,适用于 MySQL 8,可重复执行。初始化平台管理员的库存管理权限,补齐精确预占、原退货单来源追溯、完成结果幂等快照和箱级库位字段,移除“一签收单只能一箱”的旧唯一索引;不修改退货、库存等业务数据。数据库凭据从本机环境配置读取;执行失败应保留原错误,修复后重跑,不清理验收业务单据。同批结构已同步到基线、海外统一增量与 `db/incremental.sql`。
@@ -293,7 +293,7 @@ export async function verifyReturnOutbound(context, merchantToken, pdaToken, {
const before = await snapshot()
const carton = before.carton
require('出库箱历史明细归属及SKU数量有效', carton?.cartonMark === cartonMark && carton.customerId === customerId
&& carton.overseasWarehouseId === warehouseId && carton.sourceReturnNo === returnNo && carton.status === 'AVAILABLE'
&& carton.overseasWarehouseId === warehouseId && carton.sourceReturnNo === returnNo && carton.status === 'OUTBOUND_PENDING'
&& carton.items?.length > 0 && new Set(carton.items.map(row => row.skuCode)).size === carton.items.length
&& Number(carton.skuTypes) === carton.items.length
&& carton.items.every(row => nonBlank(row.skuCode) && Number.isSafeInteger(Number(row.quantity)) && Number(row.quantity) > 0)
@@ -315,7 +315,7 @@ export async function verifyReturnOutbound(context, merchantToken, pdaToken, {
const quantities = new Map(carton.items.map(row => [row.skuCode, Number(row.quantity)]))
require('待出库SKU库存唯一且足够', new Set(before.balances.map(row => row.skuCode)).size === before.balances.length
&& [...quantities].every(([sku, quantity]) => before.balances.some(row => row.skuCode === sku
&& Number(row.totalQuantity) >= quantity && Number(row.availableQuantity) >= quantity && Number(row.cartonQuantity) >= 1)))
&& Number(row.totalQuantity) >= quantity && Number(row.cartonQuantity) >= 1)))
const pickBody = { outboundPalletItemId: item.outboundPalletItemId, inventoryPositionId: null,
packages: 1, cartonMarks: [position.cartonMark], idempotencyKey: key(context, `${action}-PICK`) }
await command(pdaToken, 'POST', 'PDA扫描真实退货箱', `${path}/pick`, pickBody)
@@ -323,7 +323,7 @@ export async function verifyReturnOutbound(context, merchantToken, pdaToken, {
const expectedBalances = before.balances.map(row => {
const quantity = quantities.get(row.skuCode)
return quantity === undefined ? row : { ...row, totalQuantity: Number(row.totalQuantity) - quantity,
availableQuantity: Number(row.availableQuantity) - quantity, cartonQuantity: Number(row.cartonQuantity) - 1 }
cartonQuantity: Number(row.cartonQuantity) - 1 }
})
const withoutTime = rows => rows.map(({ updatedAt, ...row }) => row)
require('逐SKU扣减实际件数与一个可用箱且其他SKU不变', facts(withoutTime(picked.balances)) === facts(withoutTime(expectedBalances)),
@@ -37,7 +37,7 @@ test('relabelled outbound refuses another relabel application before creating an
function outboundHarness(fault = '', quantity = 5, multiSku = false, wire = true) {
const calls = []
let picked = false, completed = false, duplicate = false, drift = false
let picked = false, completed = false, duplicate = false, drift = false, reserved = true
const context = { marker: 'fixture', token: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } },
createdResources: { phase4OverseasReturnReceipt: {} }, addStep() {},
assert(_m, name, _r, passed) { assert.ok(passed, name) },
@@ -56,14 +56,14 @@ function outboundHarness(fault = '', quantity = 5, multiSku = false, wire = true
} else if (fault === 'replay deducts') duplicate = true
data = { status: 'IN_PROGRESS' }
} else if (path.endsWith('/complete')) { completed = true; data = { status: 'COMPLETED' } }
else if (path.endsWith('/candidates')) data = [{ returnNo: 'RT-1', cartonMark: 'BOX' }]
else if (path.endsWith('/outbound-orders/return')) data = { taskNo: 'TASK', outboundNo: 'OUT' }
else if (path.endsWith('/candidates')) { reserved = false; data = [{ returnNo: 'RT-1', cartonMark: 'BOX' }] }
else if (path.endsWith('/outbound-orders/return')) { reserved = true; data = { taskNo: 'TASK', outboundNo: 'OUT' } }
else if (path.endsWith('/return-inventory')) {
assert.equal(url.searchParams.get('customerId'), '123'); assert.equal(url.searchParams.get('overseasWarehouseId'), '42')
let deducted = picked ? (fault === 'package count deduction' ? 1 : quantity) : 0
if (duplicate || (completed && fault === 'complete deducts')) deducted += quantity
const stock = { customerId: fault === 'wrong scope' ? 'other' : '123', overseasWarehouseId: '42', skuCode: 'SKU',
totalQuantity: quantity - deducted, availableQuantity: quantity - deducted,
totalQuantity: quantity - deducted, availableQuantity: reserved ? 0 : quantity - deducted,
cartonQuantity: picked && fault !== 'carton not deducted' ? 0 : 1, updatedAt: picked ? 'after' : 'before' }
const unrelated = { ...stock, skuCode: 'OTHER', totalQuantity: drift ? 8 : 9, availableQuantity: 9, cartonQuantity: 1 }
data = { total: fault === 'truncated page' ? 3 : 2, records: url.searchParams.get('page') === '1' ? [stock]
@@ -77,7 +77,7 @@ function outboundHarness(fault = '', quantity = 5, multiSku = false, wire = true
data = { records: rows.reverse(), total: rows.length }
} else if (path.includes('/return-inventory/cartons/')) data = { cartonMark: 'BOX', customerId: '123',
overseasWarehouseId: '42', sourceReturnNo: 'RT-1', relabelNo: 'RL-1', relabelStatus: 'COMPLETED',
status: picked ? 'OUTBOUNDED' : 'AVAILABLE', skuTypes: 1, totalQuantity: quantity,
status: picked ? 'OUTBOUNDED' : reserved ? 'OUTBOUND_PENDING' : 'AVAILABLE', skuTypes: 1, totalQuantity: quantity,
receivedAt: picked && fault === 'history time drift' ? '2026-10-02T17:54:38.305' : '2026-10-02T17:52:56.794',
items: [{ skuCode: 'SKU', quantity: picked && fault === 'history erased' ? 0 : quantity }] }
else if (path.endsWith('/outbound-orders/OUT')) data = { outbound: { outboundNo: 'OUT', outboundType: 'RETURN_OUTBOUND', status: completed ? 'OUTBOUNDED' : 'WAIT_PICK' }, pallets: [{ id: '9', cartonMarks: 'BOX', pickedPackageCount: picked ? 1 : 0 }] }
@@ -89,7 +89,7 @@ function outboundHarness(fault = '', quantity = 5, multiSku = false, wire = true
if (multiSku && path.endsWith('/return-inventory')) {
data.total += 1
if (url.searchParams.get('page') === '2') data.records.push({ customerId: '123', overseasWarehouseId: '42',
skuCode: 'SKU-B', totalQuantity: picked ? 0 : 2, availableQuantity: picked ? 0 : 2, cartonQuantity: picked ? 0 : 1 })
skuCode: 'SKU-B', totalQuantity: picked ? 0 : 2, availableQuantity: reserved ? 0 : picked ? 0 : 2, cartonQuantity: picked ? 0 : 1 })
}
if (multiSku && path.includes('/return-inventory/cartons/')) {
data.skuTypes = 2; data.totalQuantity += 2; data.items.push({ skuCode: 'SKU-B', quantity: 2 })
@@ -0,0 +1,49 @@
-- 本地四流程结构补齐;目标 baiyue_lastleg;MySQL 8;不删除或回填业务数据。
USE `baiyue_lastleg`;
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS ensure_local_flow_column;
DELIMITER $$
CREATE PROCEDURE ensure_local_flow_column(IN t VARCHAR(64), IN n VARCHAR(64), IN d TEXT)
BEGIN
IF NOT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=t AND column_name=n) THEN
SET @local_flow_ddl=CONCAT('ALTER TABLE `',t,'` ADD COLUMN `',n,'` ',d);
PREPARE local_flow_stmt FROM @local_flow_ddl; EXECUTE local_flow_stmt; DEALLOCATE PREPARE local_flow_stmt;
END IF;
END$$
DELIMITER ;
CALL ensure_local_flow_column('ll_inbound_fba_appointment','appointment_end_at','DATETIME(3) NULL COMMENT ''预约结束时间,未明确时为空''');
CALL ensure_local_flow_column('ll_inbound_fba_appointment','version','INT NOT NULL DEFAULT 0 COMMENT ''并发版本''');
CALL ensure_local_flow_column('ll_warehouse_transfer_relabel_binding','source_allocation_id','BIGINT NULL COMMENT ''已有库存转运的原始来源分配ID''');
CALL ensure_local_flow_column('ll_warehouse_transfer_relabel_binding','relabel_item_id','BIGINT NULL COMMENT ''本票关联换标明细ID''');
CALL ensure_local_flow_column('ll_warehouse_transfer_inventory_allocation','sku_code','VARCHAR(128) NULL COMMENT ''本次来源分配的实际SKU快照''');
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_receipt_command (
id BIGINT NOT NULL COMMENT '收货命令主键',
customer_id VARCHAR(128) NOT NULL COMMENT '客户标识',
transfer_order_id BIGINT NOT NULL COMMENT '转运单标识',
source_inventory_position_id BIGINT NOT NULL COMMENT '本次收货来源库存位置',
idempotency_key VARCHAR(255) NOT NULL COMMENT '原始收货幂等键',
request_hash CHAR(64) NOT NULL COMMENT '完整原始收货请求摘要',
request_json JSON NOT NULL COMMENT '完整原始收货请求快照',
target_warehouse_id BIGINT NOT NULL COMMENT '实际目标海外收货仓',
target_pallet_id BIGINT NOT NULL COMMENT '目标实物托盘',
target_location_id BIGINT NOT NULL COMMENT '目标实际库位',
target_hash CHAR(64) NOT NULL COMMENT '共享目标资源绑定摘要',
target_json JSON NOT NULL COMMENT '权威目标仓托盘库位及目的仓快照',
reservation_key VARCHAR(128) NOT NULL COMMENT '同转运同目标托盘共享资源占用键',
resource_lock_id BIGINT NULL COMMENT '已确认有效的海外资源锁标识',
status VARCHAR(32) NOT NULL COMMENT '命令状态PREPARED准备完成RESERVED资源占用COMPLETED收货完成',
created_at DATETIME(3) NOT NULL COMMENT '命令创建时间',
updated_at DATETIME(3) NOT NULL COMMENT '命令最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_wh_receipt_cmd_source (transfer_order_id, source_inventory_position_id),
UNIQUE KEY uk_ll_wh_receipt_cmd_key (customer_id, idempotency_key),
KEY idx_ll_wh_receipt_cmd_target (customer_id, transfer_order_id, target_pallet_id),
CONSTRAINT ck_ll_wh_receipt_cmd_status CHECK (status IN ('PREPARED','RESERVED','COMPLETED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转运逐来源收货原请求与共享目标资源占用命令';
DROP PROCEDURE IF EXISTS ensure_local_flow_column;
SELECT 'LOCAL_FLOW_SCHEMA_READY' AS migration_status, DATABASE() AS target_database;
@@ -0,0 +1,13 @@
-- MySQL 8,本地尾程库,可重复执行;不修改现有清点数量或自动释放未完成任务。
USE baiyue_lastleg;
-- 退货箱内清点作业会话:每位操作员只能认领一个批次,每个批次只能被一人认领。
CREATE TABLE IF NOT EXISTS ll_return_carton_count_session (
operator_id VARCHAR(128) NOT NULL COMMENT '平台PDA操作员标识',
warehouse_id VARCHAR(128) NOT NULL COMMENT '认领时绑定海外仓标识',
receipt_id BIGINT NULL COMMENT '正在清点的签收批次,完成后释放为NULL',
started_at DATETIME(3) NULL COMMENT '本次开始清点时间',
created_at DATETIME(3) NOT NULL COMMENT '会话创建时间',
updated_at DATETIME(3) NOT NULL COMMENT '会话更新时间',
PRIMARY KEY(operator_id),
UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id)
) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话';
@@ -0,0 +1,33 @@
-- 退货托盘签收箱内清点;MySQL 8;仅在目标尾程库执行,可重复运行。
USE baiyue_lastleg;
SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='count_status'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN count_status VARCHAR(16) NOT NULL DEFAULT ''CONFIRMED'' COMMENT ''箱内清点状态 PENDING待清点 CONFIRMED已确认''');
PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt;
SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='counted_at'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点确认时间''');
PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt;
SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='counted_by'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点确认操作人''');
PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt;
SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND index_name='idx_ll_return_count'),'SELECT 1','CREATE INDEX idx_ll_return_count ON ll_return_inventory_carton(overseas_warehouse_id,count_status,status,source_receipt_id)');
PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt;
-- 历史箱:只迁移整批仍可用、没有换标/合箱占用、未确认清点的托盘签收箱。
-- 已进入履约或合箱的批次不回退;后续需要逐批人工核实。
UPDATE ll_return_inventory_carton c
JOIN (SELECT source_receipt_id FROM (SELECT a.source_receipt_id
FROM ll_return_inventory_carton a WHERE a.source_receipt_id IS NOT NULL AND a.source_unit_type='PALLET'
GROUP BY a.source_receipt_id
HAVING SUM(a.status<>'AVAILABLE' OR a.relabel_application_id IS NOT NULL OR a.relabel_no IS NOT NULL OR a.counted_at IS NOT NULL)=0
AND SUM(EXISTS(SELECT 1 FROM ll_return_inventory_carton_item i WHERE i.carton_id=a.id AND COALESCE(i.packing_reserved_quantity,0)>0))=0
AND SUM(EXISTS(SELECT 1 FROM ll_return_packing_trace t WHERE t.source_carton_mark=a.carton_mark OR t.target_carton_mark=a.carton_mark))=0
) pending) p ON p.source_receipt_id=c.source_receipt_id
SET c.count_status='PENDING' WHERE c.source_unit_type='PALLET' AND c.counted_at IS NULL;
-- 退货箱内清点作业会话:每位操作员只能认领一个批次,每个批次只能被一人认领。
CREATE TABLE IF NOT EXISTS ll_return_carton_count_session (
operator_id VARCHAR(128) NOT NULL COMMENT '平台PDA操作员标识',
warehouse_id VARCHAR(128) NOT NULL COMMENT '认领时绑定海外仓标识',
receipt_id BIGINT NULL COMMENT '正在清点的签收批次,完成后释放为NULL',
started_at DATETIME(3) NULL COMMENT '本次开始清点时间',
created_at DATETIME(3) NOT NULL COMMENT '会话创建时间',
updated_at DATETIME(3) NOT NULL COMMENT '会话更新时间',
PRIMARY KEY(operator_id),
UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id)
) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话';
@@ -0,0 +1,13 @@
-- 退货业务统一单号;只修改已验证关联的单号快照,保留订单ID、库存、清点、换标及履约状态。
USE baiyue_lastleg;
UPDATE ll_order o
JOIN ll_return_order r ON r.lastleg_order_id=o.id AND r.customer_id=o.customer_id
SET o.order_no=r.return_no, r.lastleg_order_no=r.return_no
WHERE o.order_type='RETURN_RELABEL' AND o.source_order_no=r.return_no
AND (NOT (o.order_no <=> r.return_no) OR NOT (r.lastleg_order_no <=> r.return_no));
-- 应返回零行;非零表示来源关系异常,须核实,脚本不猜测或改绑。
SELECT r.return_no, o.order_no, r.lastleg_order_no
FROM ll_return_order r JOIN ll_order o ON o.id=r.lastleg_order_id AND o.customer_id=r.customer_id
WHERE o.order_type='RETURN_RELABEL'
AND (NOT (o.order_no <=> r.return_no) OR NOT (r.lastleg_order_no <=> r.return_no));