This commit is contained in:
2026-10-16 17:43:07 +08:00
parent c9186c37a5
commit 9ea03eb540
7 changed files with 168 additions and 29 deletions
@@ -3270,7 +3270,7 @@ CREATE TABLE ll_warehouse_transfer_carton (
sku_items_json JSON NULL COMMENT '实收箱内SKU及逐项件数明细', sku_items_json JSON NULL COMMENT '实收箱内SKU及逐项件数明细',
id bigint NOT NULL COMMENT '预报箱主键', id bigint NOT NULL COMMENT '预报箱主键',
transfer_order_id bigint NOT NULL COMMENT '转运单ID', transfer_order_id bigint NOT NULL COMMENT '转运单ID',
transfer_item_id bigint NOT NULL COMMENT '转运申报明细ID', transfer_item_id bigint DEFAULT NULL COMMENT '转运申报明细ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
carton_mark varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '系统生成的一箱一码箱唛', carton_mark varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '系统生成的一箱一码箱唛',
customer_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户原箱唛,用于换标现场绑定', customer_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户原箱唛,用于换标现场绑定',
@@ -0,0 +1,43 @@
package com.baiyue.lastleg.relabel.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.List;
/** 封装换标状态命令的客户隔离查询与持久化。 */
@Repository
public class RelabelStatusRepository {
@Resource private RelabelApplicationMapper applications;
@Resource private LastlegOrderMapper orders;
@Resource private ReturnOrderMapper returnOrders;
@Resource private ReturnInventoryCartonMapper returnCartons;
/** 锁定申请,平台入口从单据解析客户。 */
public RelabelApplication lockApplication(String customer, String no) {
return StringUtils.hasText(customer) ? applications.lockNo(customer, no) : applications.lockAnyByRelabelNo(no);
}
/** 读取申请主订单。 */
public LastlegOrder findPrimaryOrder(Long id) { return orders.selectById(id); }
/** 保存申请状态及资金状态。 */
public void saveApplication(RelabelApplication application) { applications.updateById(application); }
/** 保存关联订单状态。 */
public void saveOrder(LastlegOrder order) { orders.updateById(order); }
/** 读取同一客户下关联的退货单。 */
public List<ReturnOrder> linkedReturnOrders(String customer, Long id) {
return returnOrders.selectList(new LambdaQueryWrapper<ReturnOrder>()
.eq(ReturnOrder::getRelabelApplicationId, id).eq(ReturnOrder::getCustomerId, customer));
}
/** 保存退货单换标状态。 */
public void saveReturnOrder(ReturnOrder order) { returnOrders.updateById(order); }
/** 核对退货箱实际已换标数量。 */
public long relabelledQuantity(Long id) { return returnCartons.totalRelabelledQuantity(id); }
}
@@ -82,6 +82,7 @@ public class RelabelApplicationService implements WalletResultHandler {
@Resource private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers; @Resource private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
@Resource private RelabelApplicationMapper applications; @Resource private RelabelApplicationMapper applications;
@Resource private com.baiyue.lastleg.relabel.repository.RelabelStatusRepository statusRepository;
@Resource private RelabelItemMapper items; @Resource private RelabelItemMapper items;
@Resource private RelabelStatusHistoryMapper histories; @Resource private RelabelStatusHistoryMapper histories;
@Resource private LastlegOrderMapper orders; @Resource private LastlegOrderMapper orders;
@@ -621,6 +622,7 @@ public class RelabelApplicationService implements WalletResultHandler {
Objects.requireNonNullElse(value.getPackageCount(), 0), value.getCurrentStatus())).toList(), evidence); Objects.requireNonNullElse(value.getPackageCount(), 0), value.getCurrentStatus())).toList(), evidence);
} }
/** 统一执行换标单审核、作业状态流转和作废。 */
@Transactional @Transactional
public RelabelResponses.Detail changeStatus(String customerId, String relabelNo, String operatorId, public RelabelResponses.Detail changeStatus(String customerId, String relabelNo, String operatorId,
RelabelRequests.ChangeStatus request) { RelabelRequests.ChangeStatus request) {
@@ -639,12 +641,11 @@ public class RelabelApplicationService implements WalletResultHandler {
return changeStatus(customerId, order.getRelabelOrderNo(), operatorId, request, true); return changeStatus(customerId, order.getRelabelOrderNo(), operatorId, request, true);
} }
/** 在申请行锁内执行审核及作废,保留实际作业与状态历史。 */
private RelabelResponses.Detail changeStatus(String customerId, String relabelNo, String operatorId, private RelabelResponses.Detail changeStatus(String customerId, String relabelNo, String operatorId,
RelabelRequests.ChangeStatus request, RelabelRequests.ChangeStatus request,
boolean directTailOrderReview) { boolean directTailOrderReview) {
RelabelApplication application = StringUtils.hasText(customerId) RelabelApplication application = statusRepository.lockApplication(customerId, relabelNo);
? applications.lockNo(customerId, relabelNo)
: applications.lockAnyByRelabelNo(relabelNo);
if (application == null) throw notFound("Relabel application not found"); if (application == null) throw notFound("Relabel application not found");
customerId = application.getCustomerId(); customerId = application.getCustomerId();
String next = request.status().trim().toUpperCase(); String next = request.status().trim().toUpperCase();
@@ -652,19 +653,20 @@ public class RelabelApplicationService implements WalletResultHandler {
if ("REJECTED".equals(next) && !StringUtils.hasText(request.remark())) { if ("REJECTED".equals(next) && !StringUtils.hasText(request.remark())) {
throw badRequest("审核驳回必须填写原因"); throw badRequest("审核驳回必须填写原因");
} }
LastlegOrder primaryOrder = orders.selectById(application.getLastlegOrderId()); LastlegOrder primaryOrder = statusRepository.findPrimaryOrder(application.getLastlegOrderId());
boolean directApplication = primaryOrder != null && "RELABEL_APPLICATION".equals(primaryOrder.getOrderType()); boolean directApplication = primaryOrder != null && "RELABEL_APPLICATION".equals(primaryOrder.getOrderType());
if (PENDING_REVIEW.equals(application.getStatus()) && directApplication != directTailOrderReview) { if (!"CANCELLED".equals(next) && PENDING_REVIEW.equals(application.getStatus()) && directApplication != directTailOrderReview) {
throw conflict(directApplication throw conflict(directApplication
? "直接创建的贴标单请在尾程订单审核" ? "直接创建的贴标单请在尾程订单审核"
: "关联业务产生的换标单请在换标单审核"); : "关联业务产生的换标单请在换标单审核");
} }
if (next.equals(application.getStatus())) return detail(customerId, relabelNo); if (next.equals(application.getStatus())) return detail(customerId, relabelNo);
validateTransition(application.getStatus(), next); validateTransition(application.getStatus(), next);
if (Set.of("REJECTED", "CANCELLED").contains(next) && itemList(application.getId()).stream() // 作废仅释放申请占用,已经发生的换标事实继续保留;驳回仍禁止回退实际作业。
if ("REJECTED".equals(next) && itemList(application.getId()).stream()
.filter(item -> item.getReturnCartonId() != null) .filter(item -> item.getReturnCartonId() != null)
.anyMatch(item -> returnCartons.totalRelabelledQuantity(item.getReturnCartonId()) > 0)) .anyMatch(item -> statusRepository.relabelledQuantity(item.getReturnCartonId()) > 0))
throw conflict("该退货任务已有实际商品换标事实,不能取消并恢复为未换标库存;请按异常完工或退回重做处理"); throw conflict("该退货任务已有实际商品换标事实,不能驳回并恢复为未换标库存;请按异常完工或退回重做处理");
if (PENDING_REVIEW.equals(application.getStatus()) && PENDING_PROCESSING.equals(next) if (PENDING_REVIEW.equals(application.getStatus()) && PENDING_PROCESSING.equals(next)
&& itemList(application.getId()).stream().anyMatch(item -> "RETURN_BULK_SKU_LABEL".equals(operationType(item)))) { && itemList(application.getId()).stream().anyMatch(item -> "RETURN_BULK_SKU_LABEL".equals(operationType(item)))) {
boolean hasCartonFile = StringUtils.hasText(application.getCartonTotalFileId()); boolean hasCartonFile = StringUtils.hasText(application.getCartonTotalFileId());
@@ -679,20 +681,20 @@ public class RelabelApplicationService implements WalletResultHandler {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
application.setStatus(next); application.setStatus(next);
application.setUpdatedAt(now); application.setUpdatedAt(now);
applications.updateById(application); statusRepository.saveApplication(application);
List<LastlegOrder> linkedOrders = tailOrders(application); List<LastlegOrder> linkedOrders = tailOrders(application);
if (linkedOrders.isEmpty()) throw conflict("Linked tail-leg order is missing"); if (linkedOrders.isEmpty()) throw conflict("Linked tail-leg order is missing");
if (PENDING_PROCESSING.equals(next)) { if (PENDING_PROCESSING.equals(next)) {
application.setFulfillmentStatus("WAIT_RELABEL"); application.setFulfillmentStatus("WAIT_RELABEL");
applications.updateById(application); statusRepository.saveApplication(application);
} }
if (CUSTOMER_CONFIRM_PENDING.equals(next)) { if (CUSTOMER_CONFIRM_PENDING.equals(next)) {
application.setFulfillmentStatus("WAIT_CUSTOMER_CONFIRMATION"); application.setFulfillmentStatus("WAIT_CUSTOMER_CONFIRMATION");
applications.updateById(application); statusRepository.saveApplication(application);
} else if ("COMPLETED".equals(next)) { } else if ("COMPLETED".equals(next)) {
boolean preDispatched = "PRE_DISPATCH_CREATED".equals(application.getFulfillmentStatus()); boolean preDispatched = "PRE_DISPATCH_CREATED".equals(application.getFulfillmentStatus());
application.setFulfillmentStatus("OUTBOUNDED"); application.setFulfillmentStatus("OUTBOUNDED");
applications.updateById(application); statusRepository.saveApplication(application);
if (!StringUtils.hasText(application.getOutboundOrderNo()) if (!StringUtils.hasText(application.getOutboundOrderNo())
&& !preDispatched) { && !preDispatched) {
releaseInventory(customerId, operatorId, application); releaseInventory(customerId, operatorId, application);
@@ -703,7 +705,7 @@ public class RelabelApplicationService implements WalletResultHandler {
if ("FROZEN".equals(application.getWalletStatus())) { if ("FROZEN".equals(application.getWalletStatus())) {
walletCommands.publish("RELEASE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null, walletCommands.publish("RELEASE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null,
"换标取消,释放冻结", 2L); "换标取消,释放冻结", 2L);
application.setWalletStatus("RELEASE_PENDING"); applications.updateById(application); application.setWalletStatus("RELEASE_PENDING"); statusRepository.saveApplication(application);
} }
} }
for (LastlegOrder order : linkedOrders) { for (LastlegOrder order : linkedOrders) {
@@ -719,27 +721,25 @@ public class RelabelApplicationService implements WalletResultHandler {
case "CANCELLED" -> "CANCELLED"; case "CANCELLED" -> "CANCELLED";
default -> mainStatus; default -> mainStatus;
}; };
// Reviewing/processing a return relabel must not move the already // 退货不回退已完成入库;转运作废只终止换标,不取消原转运主单。
// completed inbound order back to assignment or relabel waiting. if ("RETURN_RELABEL".equals(order.getOrderType())
if ("RETURN_RELABEL".equals(order.getOrderType())) { || "WAREHOUSE_TRANSFER".equals(order.getOrderType()) && "CANCELLED".equals(next)) {
orderStatus = orderBefore; orderStatus = orderBefore;
} }
order.setCurrentStatus(orderStatus); order.setCurrentStatus(orderStatus);
if (!"RETURN_RELABEL".equals(order.getOrderType())) order.setSubOrderStatus("RELABEL:" + next); if (!"RETURN_RELABEL".equals(order.getOrderType())) order.setSubOrderStatus("RELABEL:" + next);
order.setRelabelStatus(businessRelabelStatus(next)); order.setRelabelStatus(businessRelabelStatus(next));
order.setUpdatedAt(now); order.setUpdatedAt(now);
orders.updateById(order); statusRepository.saveOrder(order);
if (!Objects.equals(orderBefore, orderStatus)) { if (!Objects.equals(orderBefore, orderStatus)) {
insertOrderHistory(customerId, order.getId(), orderBefore, orderStatus, request.remark(), now); insertOrderHistory(customerId, order.getId(), orderBefore, orderStatus, request.remark(), now);
} }
} }
List<ReturnOrder> linkedReturnOrders = returnOrders.selectList(new LambdaQueryWrapper<ReturnOrder>() List<ReturnOrder> linkedReturnOrders = statusRepository.linkedReturnOrders(customerId, application.getId());
.eq(ReturnOrder::getRelabelApplicationId, application.getId())
.eq(ReturnOrder::getCustomerId, customerId));
for (ReturnOrder returnOrder : linkedReturnOrders) { for (ReturnOrder returnOrder : linkedReturnOrders) {
returnOrder.setRelabelStatus(next); returnOrder.setRelabelStatus(next);
returnOrder.setUpdatedAt(now); returnOrder.setUpdatedAt(now);
returnOrders.updateById(returnOrder); statusRepository.saveReturnOrder(returnOrder);
} }
insertHistory(customerId, application.getId(), before, next, operatorId, request.remark(), now); insertHistory(customerId, application.getId(), before, next, operatorId, request.remark(), now);
return detail(customerId, relabelNo); return detail(customerId, relabelNo);
@@ -1535,7 +1535,12 @@ public class RelabelApplicationService implements WalletResultHandler {
value.setToStatus(to); value.setEventId("RELABEL_APPLICATION"); value.setRemark(trim(remark)); value.setCreatedAt(now); value.setToStatus(to); value.setEventId("RELABEL_APPLICATION"); value.setRemark(trim(remark)); value.setCreatedAt(now);
orderHistories.insert(value); orderHistories.insert(value);
} }
/** 客户确认换标完成后禁止作废,其余状态允许终止申请。 */
private static void validateTransition(String from, String to) { private static void validateTransition(String from, String to) {
if ("CANCELLED".equals(to)) {
if (Set.of(READY_FOR_OUTBOUND, "COMPLETED").contains(from)) throw conflict("换标完成的单据不能作废");
return;
}
boolean allowed = PENDING_REVIEW.equals(from) && Set.of(PENDING_PROCESSING, "REJECTED", "CANCELLED").contains(to) boolean allowed = PENDING_REVIEW.equals(from) && Set.of(PENDING_PROCESSING, "REJECTED", "CANCELLED").contains(to)
|| PENDING_PROCESSING.equals(from) && Set.of("PROCESSING", "REJECTED", "CANCELLED").contains(to) || PENDING_PROCESSING.equals(from) && Set.of("PROCESSING", "REJECTED", "CANCELLED").contains(to)
|| "PROCESSING".equals(from) && Set.of(CUSTOMER_CONFIRM_PENDING, "REJECTED", "CANCELLED").contains(to) || "PROCESSING".equals(from) && Set.of(CUSTOMER_CONFIRM_PENDING, "REJECTED", "CANCELLED").contains(to)
@@ -187,8 +187,12 @@ public final class WarehouseTransferRequests {
@Size(max = 255) String productNameZh, @Size(max = 255) String productNameZh,
@Positive int packageCount, @Positive int packageCount,
@Size(max = 255) String shippingMark, @Size(max = 255) String shippingMark,
@Positive Long quantity @Positive Long quantity,
@Size(max = 5000) List<@NotBlank @jakarta.validation.constraints.Pattern(regexp = "BX-[A-Z0-9]{1,40}") String> cartonMarks
) { ) {
public Item(String skuCode, String productNameZh, int packageCount, String shippingMark, Long quantity) {
this(skuCode, productNameZh, packageCount, shippingMark, quantity, null);
}
public Item(String skuCode, String productNameZh, int packageCount, String shippingMark) { public Item(String skuCode, String productNameZh, int packageCount, String shippingMark) {
this(skuCode, productNameZh, packageCount, shippingMark, null); this(skuCode, productNameZh, packageCount, shippingMark, null);
} }
@@ -199,6 +199,7 @@ public class WarehouseTransferService {
if (itemPackages != request.packageCount()) { if (itemPackages != request.packageCount()) {
throw badRequest("Cargo item package total must equal transfer package count"); throw badRequest("Cargo item package total must equal transfer package count");
} }
validatePrintedCartonMarks(request);
validateRelabelRequest(customerId, request); validateRelabelRequest(customerId, request);
validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(),
request.privateDestinationAddress(), request.privateDestinationPostalCode(), request.privateDestinationAddress(), request.privateDestinationPostalCode(),
@@ -321,7 +322,8 @@ public class WarehouseTransferService {
for (int itemCarton = 0; itemCarton < source.packageCount(); itemCarton++) { for (int itemCarton = 0; itemCarton < source.packageCount(); itemCarton++) {
cartonIndex++; cartonIndex++;
insertTransferCarton(customerId, transferId, itemId, transferNo, insertTransferCarton(customerId, transferId, itemId, transferNo,
cartonIndex, request.packageCount(), itemCarton < customerMarks.size() ? customerMarks.get(itemCarton) : null, now); cartonIndex, request.packageCount(), itemCarton < customerMarks.size() ? customerMarks.get(itemCarton) : null, now,
source.cartonMarks() == null ? null : source.cartonMarks().get(itemCarton));
} }
insertOrderDetail(customerId, order.getId(), transferNo, index, source, request, now); insertOrderDetail(customerId, order.getId(), transferNo, index, source, request, now);
} }
@@ -352,6 +354,7 @@ public class WarehouseTransferService {
if (itemPackages != request.packageCount()) { if (itemPackages != request.packageCount()) {
throw badRequest("Cargo item package total must equal transfer package count"); throw badRequest("Cargo item package total must equal transfer package count");
} }
validatePrintedCartonMarks(request);
validateRelabelRequest(customerId, request); validateRelabelRequest(customerId, request);
validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(),
request.privateDestinationAddress(), request.privateDestinationPostalCode(), request.privateDestinationAddress(), request.privateDestinationPostalCode(),
@@ -439,8 +442,9 @@ public class WarehouseTransferService {
String customerMark = itemCarton < customerMarks.size() ? customerMarks.get(itemCarton) : null; String customerMark = itemCarton < customerMarks.size() ? customerMarks.get(itemCarton) : null;
if (existingCarton == null) { if (existingCarton == null) {
insertTransferCarton(customerId, transfer.getId(), itemId, transferNo, cartonIndex, insertTransferCarton(customerId, transfer.getId(), itemId, transferNo, cartonIndex,
request.packageCount(), customerMark, now); request.packageCount(), customerMark, now, source.cartonMarks() == null ? null : source.cartonMarks().get(itemCarton));
} else { } else {
if (source.cartonMarks() != null && !existingCarton.getCartonMark().equals(source.cartonMarks().get(itemCarton))) throw conflict("已保存箱唛不能替换,请重新读取草稿");
existingCarton.setTransferItemId(itemId); existingCarton.setTotalCartons(request.packageCount()); existingCarton.setTransferItemId(itemId); existingCarton.setTotalCartons(request.packageCount());
existingCarton.setCustomerCartonMark(customerMark); existingCarton.setUpdatedAt(now); existingCarton.setCustomerCartonMark(customerMark); existingCarton.setUpdatedAt(now);
transferCartons.updateById(existingCarton); transferCartons.updateById(existingCarton);
@@ -2134,6 +2138,17 @@ public class WarehouseTransferService {
return order; return order;
} }
private void validatePrintedCartonMarks(WarehouseTransferRequests.Create request) {
Set<String> marks = new java.util.HashSet<>();
for (var item : request.items()) {
if (item.cartonMarks() == null) continue;
if (item.cartonMarks().size() != item.packageCount()) throw badRequest("已生成箱唛数量必须等于预报箱数,请重新打印");
for (String mark : item.cartonMarks()) {
if (mark == null || !mark.matches("BX-[A-Z0-9]{1,40}") || !marks.add(mark)) throw badRequest("已生成箱唛格式错误或重复,请重新打印");
}
}
}
private long insertTransferItem(String customerId, long transferId, WarehouseTransferRequests.Item source, private long insertTransferItem(String customerId, long transferId, WarehouseTransferRequests.Item source,
LocalDateTime now) { LocalDateTime now) {
WarehouseTransferItem item = new WarehouseTransferItem(); WarehouseTransferItem item = new WarehouseTransferItem();
@@ -2148,10 +2163,10 @@ public class WarehouseTransferService {
private void insertTransferCarton(String customerId, long transferId, long transferItemId, private void insertTransferCarton(String customerId, long transferId, long transferItemId,
String transferNo, int cartonIndex, int totalCartons, String transferNo, int cartonIndex, int totalCartons,
String customerCartonMark, LocalDateTime now) { String customerCartonMark, LocalDateTime now, String printedMark) {
WarehouseTransferCarton carton = new WarehouseTransferCarton(); WarehouseTransferCarton carton = new WarehouseTransferCarton();
carton.setId(IdWorker.getId()); carton.setTransferOrderId(transferId); carton.setTransferItemId(transferItemId); carton.setId(IdWorker.getId()); carton.setTransferOrderId(transferId); carton.setTransferItemId(transferItemId);
carton.setCustomerId(customerId); carton.setCartonMark(LastlegOrderNoGenerator.compactCartonMark(carton.getId())); carton.setCustomerId(customerId); carton.setCartonMark(printedMark == null ? LastlegOrderNoGenerator.compactCartonMark(carton.getId()) : printedMark);
carton.setCustomerCartonMark(trim(customerCartonMark)); carton.setCustomerCartonMark(trim(customerCartonMark));
carton.setCartonIndex(cartonIndex); carton.setTotalCartons(totalCartons); carton.setStatus("EXPECTED"); carton.setCartonIndex(cartonIndex); carton.setTotalCartons(totalCartons); carton.setStatus("EXPECTED");
carton.setCreatedAt(now); carton.setUpdatedAt(now); transferCartons.insert(carton); carton.setCreatedAt(now); carton.setUpdatedAt(now); transferCartons.insert(carton);
@@ -230,6 +230,11 @@ class RelabelApplicationServiceTest {
}); });
lenient().when(pdaTasks.updateWithVersion(any(), any())).thenReturn(1); lenient().when(pdaTasks.updateWithVersion(any(), any())).thenReturn(1);
ReflectionTestUtils.setField(service, "applications", applications); ReflectionTestUtils.setField(service, "applications", applications);
var statusRepository = new com.baiyue.lastleg.relabel.repository.RelabelStatusRepository();
ReflectionTestUtils.setField(statusRepository, "applications", applications);
ReflectionTestUtils.setField(statusRepository, "orders", orders);
ReflectionTestUtils.setField(statusRepository, "returnOrders", returnOrders);
ReflectionTestUtils.setField(service, "statusRepository", statusRepository);
ReflectionTestUtils.setField(service, "transferRelabelCompletion", transferRelabelCompletion); ReflectionTestUtils.setField(service, "transferRelabelCompletion", transferRelabelCompletion);
ReflectionTestUtils.setField(service, "items", items); ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "histories", histories); ReflectionTestUtils.setField(service, "histories", histories);
@@ -1305,6 +1310,58 @@ class RelabelApplicationServiceTest {
verify(applications, never()).insert(any(RelabelApplication.class)); verify(applications, never()).insert(any(RelabelApplication.class));
} }
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"PENDING_REVIEW", "PENDING_PROCESSING", "PROCESSING", "CUSTOMER_CONFIRM_PENDING", "REJECTED"})
void unfinishedApplicationsCanBeVoidedAndRetainTransferMainStatus(String status) {
var app = application(11L, "RL-VOID", "10001", "void-key", "hash", status);
app.setLastlegOrderId(21L); app.setWalletStatus("FROZEN");
when(applications.lockNo("10001", "RL-VOID")).thenReturn(app);
stubDetail(app);
var tail = order(21L, "10001", "WT-1", "INBOUND_COMPLETED"); tail.setOrderType("WAREHOUSE_TRANSFER");
when(orders.selectById(21L)).thenReturn(tail);
service.changeStatus("10001", "RL-VOID", "operator", new RelabelRequests.ChangeStatus("CANCELLED", "废弃预填换标要求"));
assertEquals("CANCELLED", app.getStatus());
assertEquals("INBOUND_COMPLETED", tail.getCurrentStatus());
assertEquals("CANCELLED", tail.getRelabelStatus());
assertEquals("RELEASE_PENDING", app.getWalletStatus());
verify(walletCommands).publish(eq("RELEASE"), eq(10001L), eq("RELABEL_ORDER"), eq("RL-VOID"), isNull(), anyString(), eq(2L));
verify(histories).insert(any(RelabelStatusHistory.class));
}
@Test void repeatedVoidReturnsExistingRecordWithoutReleasingAgain() {
var app = application(11L, "RL-VOID-AGAIN", "10001", "key", "hash", "CANCELLED");
app.setLastlegOrderId(21L);
when(applications.lockNo("10001", app.getRelabelNo())).thenReturn(app); stubDetail(app);
var result = service.changeStatus("10001", app.getRelabelNo(), "operator", new RelabelRequests.ChangeStatus("CANCELLED", "作废"));
assertEquals("CANCELLED", result.application().status());
verify(applications, never()).updateById(any(RelabelApplication.class));
verify(inventoryCommands, never()).release(anyString(), anyString(), any());
verify(histories, never()).insert(any(RelabelStatusHistory.class));
}
@Test void directPendingReviewApplicationCanBeVoidedOutsideTailOrderReview() {
var app = application(11L, "RL-DIRECT-VOID", "10001", "void-key", "hash", "PENDING_REVIEW");
app.setLastlegOrderId(21L);
when(applications.lockNo("10001", app.getRelabelNo())).thenReturn(app); stubDetail(app);
var tail = order(21L, "10001", "RL-ORDER", "WAIT_RELABEL"); tail.setOrderType("RELABEL_APPLICATION");
when(orders.selectById(21L)).thenReturn(tail);
service.changeStatus("10001", app.getRelabelNo(), "operator", new RelabelRequests.ChangeStatus("CANCELLED", "作废"));
assertEquals("CANCELLED", tail.getCurrentStatus());
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"READY_FOR_OUTBOUND", "COMPLETED"})
void completedRelabelCannotBeVoided(String status) {
var app = application(11L, "RL-DONE", "10001", "key", "hash", status);
app.setLastlegOrderId(21L);
when(applications.lockNo("10001", "RL-DONE")).thenReturn(app);
when(orders.selectById(21L)).thenReturn(order(21L, "10001", "ORDER", "COMPLETED"));
var failure = assertThrows(ResponseStatusException.class, () -> service.changeStatus("10001", "RL-DONE", "operator", new RelabelRequests.ChangeStatus("CANCELLED", "作废")));
assertTrue(failure.getReason().contains("不能作废"));
verify(applications, never()).updateById(any(RelabelApplication.class));
verify(walletCommands, never()).publish(anyString(), anyLong(), anyString(), anyString(), any(), any(), anyLong());
}
private void stubDetail(RelabelApplication application) { private void stubDetail(RelabelApplication application) {
when(applications.findNo(application.getCustomerId(), application.getRelabelNo())).thenReturn(application); when(applications.findNo(application.getCustomerId(), application.getRelabelNo())).thenReturn(application);
when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 10, 6))); when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 10, 6)));
@@ -619,7 +619,7 @@ class WarehouseTransferServiceTest {
new BigDecimal("0.2500"), "WAREHOUSE_PICKUP", LocalDateTime.of(2026, 9, 12, 10, 0), new BigDecimal("0.2500"), "WAREHOUSE_PICKUP", LocalDateTime.of(2026, 9, 12, 10, 0),
null, null, "TRACK-MUST-NOT-BE-SAVED", null, "Incoming transit cargo", true, null, null, "TRACK-MUST-NOT-BE-SAVED", null, "Incoming transit cargo", true,
null, null, null, null, null, "key-incoming", null, null, null, null, null, "key-incoming",
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product A", 2, null), List.of(new WarehouseTransferRequests.Item("SKU-1", "Product A", 2, null, null, List.of("BX-PRINTED1", "BX-PRINTED2")),
new WarehouseTransferRequests.Item("SKU-2", "Product B", 3, null)), null); new WarehouseTransferRequests.Item("SKU-2", "Product B", 3, null)), null);
when(transfers.findNo(eq("TENANT-A"), anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT)); when(transfers.findNo(eq("TENANT-A"), anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT));
when(transferItems.selectList(any())).thenReturn(List.of()); when(transferItems.selectList(any())).thenReturn(List.of());
@@ -647,6 +647,8 @@ class WarehouseTransferServiceTest {
assertEquals("PENDING_BINDING", tailOrder.getValue().getRelabelStatus()); assertEquals("PENDING_BINDING", tailOrder.getValue().getRelabelStatus());
assertEquals(List.of(1, 2, 3, 4, 5), cartons.getAllValues().stream() assertEquals(List.of(1, 2, 3, 4, 5), cartons.getAllValues().stream()
.map(WarehouseTransferCarton::getCartonIndex).toList()); .map(WarehouseTransferCarton::getCartonIndex).toList());
assertEquals(List.of("BX-PRINTED1", "BX-PRINTED2"), cartons.getAllValues().subList(0, 2).stream()
.map(WarehouseTransferCarton::getCartonMark).toList());
assertTrue(cartons.getAllValues().stream().allMatch(value -> value.getCartonMark() assertTrue(cartons.getAllValues().stream().allMatch(value -> value.getCartonMark()
.matches("BX-[0-9A-Z]+"))); .matches("BX-[0-9A-Z]+")));
when(transfers.findKey("TENANT-A", request.idempotencyKey())).thenReturn(transfer.getValue()); when(transfers.findKey("TENANT-A", request.idempotencyKey())).thenReturn(transfer.getValue());
@@ -814,7 +816,20 @@ class WarehouseTransferServiceTest {
verify(transfers, never()).updateById(any(WarehouseTransferOrder.class)); verify(transfers, never()).updateById(any(WarehouseTransferOrder.class));
} }
/** 草稿预先打印与后续正式提交使用同一服务端箱唛。 */ /** 未建单时生成的箱唛必须完整且不重复。 */
@Test
void duplicateOrMismatchedPreprintedMarksRejectBeforeCreatingOrder() throws Exception {
var mapper = new ObjectMapper().findAndRegisterModules();
for (var marks : List.of(List.of("BX-ONE"), List.of("BX-ONE", "BX-ONE", "BX-3", "BX-4", "BX-5"))) {
com.fasterxml.jackson.databind.node.ObjectNode json = mapper.valueToTree(request(false));
json.remove("inboundModeValid");
((com.fasterxml.jackson.databind.node.ObjectNode) json.get("items").get(0)).set("cartonMarks", mapper.valueToTree(marks));
var input = mapper.treeToValue(json, WarehouseTransferRequests.Create.class);
assertThrows(ResponseStatusException.class, () -> service.create("TENANT-A", "USER-1", input));
}
verify(transfers, never()).insert(any(WarehouseTransferOrder.class));
}
@Test @Test
void draftCanPrintPersistedCartonLabels() { void draftCanPrintPersistedCartonLabels() {
WarehouseTransferOrder transfer = persistedTransfer(); transfer.setStatus(WarehouseTransferService.DRAFT); WarehouseTransferOrder transfer = persistedTransfer(); transfer.setStatus(WarehouseTransferService.DRAFT);