Merge remote-tracking branch 'origin/test' into test
This commit is contained in:
+14
-2
@@ -80,7 +80,13 @@ public final class DeliveryRequests {
|
||||
@Size(max=1000) String remark,
|
||||
@NotBlank @Size(max=255) String idempotencyKey,
|
||||
@Size(max=128) String skuCode,
|
||||
@jakarta.validation.constraints.Pattern(regexp = "BOX|PALLET") String unitType) {
|
||||
@jakarta.validation.constraints.Pattern(regexp = "BOX|PALLET") String unitType,
|
||||
@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = WholePieceQuantityDeserializer.class)
|
||||
@jakarta.validation.constraints.Positive Long quantity) {
|
||||
public ReturnOutbound(String recipient, String overseasWarehouseId, String returnNo,
|
||||
List<String> cartonMarks, String remark, String idempotencyKey, String skuCode, String unitType) {
|
||||
this(recipient, overseasWarehouseId, returnNo, cartonMarks, remark, idempotencyKey, skuCode, unitType, null);
|
||||
}
|
||||
public ReturnOutbound(String recipient, String overseasWarehouseId, String returnNo,
|
||||
List<String> cartonMarks, String remark, String idempotencyKey) {
|
||||
this(recipient, overseasWarehouseId, returnNo, cartonMarks, remark, idempotencyKey, null, null);
|
||||
@@ -114,7 +120,13 @@ public final class DeliveryRequests {
|
||||
public record PdaPick(@NotNull Long outboundPalletItemId, Long inventoryPositionId,
|
||||
@NotNull @Min(1) Long packages, @NotBlank @Size(max=255) String idempotencyKey,
|
||||
@Size(max=500) List<@NotBlank @Size(max=255) String> cartonMarks,
|
||||
@Size(max=128) String skuCode) {
|
||||
@Size(max=128) String skuCode,
|
||||
@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = WholePieceQuantityDeserializer.class)
|
||||
@jakarta.validation.constraints.Positive Long productQuantity) {
|
||||
public PdaPick(Long outboundPalletItemId, Long inventoryPositionId, Long packages, String idempotencyKey,
|
||||
List<String> cartonMarks, String skuCode) {
|
||||
this(outboundPalletItemId, inventoryPositionId, packages, idempotencyKey, cartonMarks, skuCode, null);
|
||||
}
|
||||
public PdaPick(Long outboundPalletItemId, Long inventoryPositionId, Long packages, String idempotencyKey) {
|
||||
this(outboundPalletItemId, inventoryPositionId, packages, idempotencyKey, List.of(), null);
|
||||
}
|
||||
|
||||
+2
-2
@@ -92,7 +92,7 @@ public final class DeliveryResponses {
|
||||
String skuCode, String inboundCode, long availablePackages,
|
||||
long pickedPackages) {}
|
||||
public record PdaTaskItem(Long outboundPalletItemId, String palletNo, String skuCode, String inboundCode,
|
||||
int packageCount, int pickedPackageCount, String status,
|
||||
List<PdaPickPosition> positions) {}
|
||||
int packageCount, int pickedPackageCount, String status,
|
||||
List<PdaPickPosition> positions, Long returnSkuQuantity) {}
|
||||
public record PdaTaskDetail(PdaTask task, List<PdaTaskItem> items) {}
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.lastleg.delivery.dto;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.core.JsonToken;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import java.io.IOException;
|
||||
|
||||
/** Piece quantities must not silently truncate a fractional JSON number. */
|
||||
public final class WholePieceQuantityDeserializer extends JsonDeserializer<Long> {
|
||||
@Override public Long deserialize(JsonParser parser, DeserializationContext context) throws IOException {
|
||||
if (parser.currentToken() != JsonToken.VALUE_NUMBER_INT)
|
||||
return (Long) context.handleUnexpectedToken(Long.class, parser);
|
||||
return parser.getLongValue();
|
||||
}
|
||||
}
|
||||
+2
@@ -28,6 +28,8 @@ public class OutboundPalletItem {
|
||||
private Integer pickedPackageCount;
|
||||
private String status;
|
||||
private String skuCode;
|
||||
/** Immutable piece quantity for a selected single-SKU return source. Null means legacy whole-carton work. */
|
||||
private Long returnSkuQuantity;
|
||||
private String cartonMarks;
|
||||
private String orderRefs;
|
||||
private String inboundCode;
|
||||
|
||||
+29
-8
@@ -499,6 +499,8 @@ public class DeliveryTransferService {
|
||||
OutboundOrder existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
|
||||
if (existing != null) return replayReturnOutbound(customerId, existing, requestHash);
|
||||
String warehouseId = request.overseasWarehouseId().trim();
|
||||
if (request.quantity() != null && (!StringUtils.hasText(request.skuCode()) || request.quantity() <= 0
|
||||
|| "PALLET".equals(request.unitType()))) throw conflict("按件数出库必须选择单SKU,不能同时整托出库");
|
||||
List<String> marks = request.cartonMarks().stream().map(String::trim).filter(StringUtils::hasText).distinct().toList();
|
||||
if (marks.size() != request.cartonMarks().size()) throw conflict("箱唛不能为空且不可重复");
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
@@ -514,6 +516,8 @@ public class DeliveryTransferService {
|
||||
existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
|
||||
if (existing != null) return replayReturnOutbound(customerId, existing, requestHash);
|
||||
for (ReturnInventoryCarton carton : locked) {
|
||||
if ("PARCEL".equals(carton.getSourceUnitType()) && !StringUtils.hasText(request.skuCode()))
|
||||
throw conflict("包裹散件只能按 SKU 出库,不能按箱号或托盘出库");
|
||||
com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountGuard.requireConfirmed(carton);
|
||||
if (!"AVAILABLE".equals(carton.getStatus())) throw conflict("退货箱已被其他作业占用:" + carton.getCartonMark());
|
||||
var contents = returnCartonItems.lockCarton(carton.getId());
|
||||
@@ -521,12 +525,22 @@ public class DeliveryTransferService {
|
||||
throw conflict("退货箱缺少明细或已被合箱计划锁定:" + carton.getCartonMark());
|
||||
if (StringUtils.hasText(request.skuCode()) && (contents.size() != 1
|
||||
|| !request.skuCode().trim().equals(contents.get(0).getSkuCode())))
|
||||
throw conflict("单SKU出库只支持指定SKU的散件库存");
|
||||
if (StringUtils.hasText(request.skuCode()) && !"PARCEL".equals(carton.getSourceUnitType()))
|
||||
throw conflict("已装箱库存请按箱唛出库");
|
||||
throw conflict("单SKU出库只支持箱内仅有指定SKU的库存;混合箱请先拆箱");
|
||||
if ("PALLET".equals(request.unitType()) && !StringUtils.hasText(carton.getPalletCode()))
|
||||
throw conflict("整托出库必须存在托盘绑定");
|
||||
}
|
||||
Map<String, Long> skuQuantities = new LinkedHashMap<>();
|
||||
if (StringUtils.hasText(request.skuCode())) {
|
||||
long available = locked.stream().mapToLong(c -> value(c.getTotalQuantity())).sum();
|
||||
long remaining = request.quantity() == null ? available : request.quantity();
|
||||
if (remaining > available) throw conflict("出库件数超过所选来源库存数量");
|
||||
for (ReturnInventoryCarton carton : locked) {
|
||||
long selected = Math.min(remaining, value(carton.getTotalQuantity()));
|
||||
if (selected > 0) skuQuantities.put(carton.getCartonMark(), selected);
|
||||
remaining -= selected;
|
||||
}
|
||||
locked = locked.stream().filter(c -> skuQuantities.containsKey(c.getCartonMark())).toList();
|
||||
}
|
||||
if ("PALLET".equals(request.unitType())) {
|
||||
String pallet = locked.get(0).getPalletCode();
|
||||
if (locked.stream().anyMatch(c -> !Objects.equals(pallet, c.getPalletCode()))) throw conflict("整托出库只能选择同一托盘");
|
||||
@@ -539,7 +553,7 @@ public class DeliveryTransferService {
|
||||
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
|
||||
outbound.setOutboundNo(orderNoGenerator.next("RETURN_OUTBOUND", warehouseId, outbound.getId()));
|
||||
outbound.setOutboundType("RETURN_OUTBOUND"); outbound.setStatus("WAIT_PICK");
|
||||
outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(marks.size()); outbound.setPalletCount("PALLET".equals(request.unitType()) ? 1 : 0);
|
||||
outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(locked.size()); outbound.setPalletCount("PALLET".equals(request.unitType()) ? 1 : 0);
|
||||
outbound.setOverseasWarehouseId(warehouseId); outbound.setIdempotencyKey(request.idempotencyKey().trim());
|
||||
outbound.setRequestHash(requestHash);
|
||||
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound);
|
||||
@@ -547,7 +561,9 @@ public class DeliveryTransferService {
|
||||
OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId);
|
||||
item.setOutboundOrderId(outbound.getId()); item.setPackageCount(1); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
|
||||
item.setCartonMarks(carton.getCartonMark()); item.setInboundCode(carton.getSourceReturnNo());
|
||||
item.setSkuCode(trim(request.skuCode()));
|
||||
// Packed stock is still picked by its physical carton mark; loose parcels use SKU scanning.
|
||||
item.setSkuCode("PARCEL".equals(carton.getSourceUnitType()) ? trim(request.skuCode()) : null);
|
||||
item.setReturnSkuQuantity(skuQuantities.get(carton.getCartonMark()));
|
||||
item.setPalletNo("PALLET".equals(request.unitType()) ? carton.getPalletCode() : null);
|
||||
item.setCreatedAt(now); outboundPallets.insert(item);
|
||||
carton.setStatus("OUTBOUND_PENDING"); carton.setUpdatedAt(now); returnCartons.updateById(carton);
|
||||
@@ -557,7 +573,7 @@ public class DeliveryTransferService {
|
||||
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
|
||||
delivery.setOverseasWarehouseId(warehouseId); delivery.setDeliveryGroupKey("RETURN:" + request.returnNo().trim());
|
||||
delivery.setStatus("WAIT_DELIVERY"); delivery.setDeliveryAddress(request.recipient().trim());
|
||||
delivery.setPackageCount(marks.size()); delivery.setPalletCount("PALLET".equals(request.unitType()) ? 1 : 0);
|
||||
delivery.setPackageCount(locked.size()); delivery.setPalletCount("PALLET".equals(request.unitType()) ? 1 : 0);
|
||||
delivery.setRemark(trim(request.remark())); delivery.setIdempotencyKey(childKey(request.idempotencyKey(), "RETURN-DELIVERY"));
|
||||
delivery.setVersion(0L); delivery.setCreatedBy(operatorId); delivery.setCreatedAt(now); delivery.setUpdatedAt(now);
|
||||
deliveryOrders.insert(delivery);
|
||||
@@ -822,7 +838,10 @@ public class DeliveryTransferService {
|
||||
requireReturnScan(outbound, item, request);
|
||||
if (value(item.getPickedPackageCount()) > 0) throw conflict("退货箱不可重复出库");
|
||||
OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(null); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick);
|
||||
returnStock.shipCarton(customerId, warehouseId, item.getInboundCode(), item.getCartonMarks(), outbound.getOutboundNo(), pick.getId());
|
||||
if (item.getReturnSkuQuantity() != null)
|
||||
returnStock.shipCartonQuantity(customerId, warehouseId, item.getInboundCode(), item.getCartonMarks(),
|
||||
outbound.getOutboundNo(), pick.getId(), item.getReturnSkuQuantity());
|
||||
else returnStock.shipCarton(customerId, warehouseId, item.getInboundCode(), item.getCartonMarks(), outbound.getOutboundNo(), pick.getId());
|
||||
item.setPickedPackageCount(request.packages().intValue()); item.setStatus("PICKED"); outboundPallets.updateById(item);
|
||||
updateOutboundPickingStatus(customerId, outbound);
|
||||
task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task);
|
||||
@@ -1287,7 +1306,7 @@ public class DeliveryTransferService {
|
||||
value(item.getPickedPackageCount()), item.getStatus(),
|
||||
("RETURN_OUTBOUND".equals(outbound.getOutboundType())
|
||||
|| ("RELABEL_OUTBOUND".equals(outbound.getOutboundType()) && item.getWarehousePalletId() == null)) ? returnPickPositions(outbound, task, item)
|
||||
: pdaPickPositions(task.getCustomerId(), boundWarehouseId, item))).toList();
|
||||
: pdaPickPositions(task.getCustomerId(), boundWarehouseId, item), item.getReturnSkuQuantity())).toList();
|
||||
return new DeliveryResponses.PdaTaskDetail(taskResult(outbound, task), items);
|
||||
}
|
||||
|
||||
@@ -1326,6 +1345,8 @@ public class DeliveryTransferService {
|
||||
|| !outbound.getCustomerId().equals(item.getCustomerId())) throw notFound("退货出库明细不存在");
|
||||
if (request.inventoryPositionId() != null || !Long.valueOf(1).equals(request.packages()) || value(item.getPackageCount()) != 1)
|
||||
throw conflict("退货出库须逐箱或逐SKU扫描,无需库存仓位");
|
||||
if (item.getReturnSkuQuantity() != null && !item.getReturnSkuQuantity().equals(request.productQuantity()))
|
||||
throw conflict("实际出库件数必须等于本明细计划件数,请核对数量");
|
||||
if (StringUtils.hasText(item.getSkuCode())) {
|
||||
if (!Objects.equals(item.getSkuCode(), trim(request.skuCode()))) throw conflict("请扫描指定的退货SKU");
|
||||
} else if (request.cartonMarks() == null || request.cartonMarks().size() != 1
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@ public final class PdaRelabelResponses {
|
||||
String applicationStatus, String taskStatus, String operationType, int itemCount,
|
||||
int expectedPackages, int relabelledPackages, int skippedPackages,
|
||||
int exceptionPackages, int version, boolean claimedByCurrentOperator,
|
||||
String reviewDecision, String reviewRemark, LocalDateTime reviewedAt) { }
|
||||
String reviewDecision, String reviewRemark, LocalDateTime reviewedAt,
|
||||
String customerName) { }
|
||||
|
||||
public record Detail(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline,
|
||||
String contactName, String contactPhone, String applicationStatus, String operationType,
|
||||
|
||||
+21
-6
@@ -72,6 +72,7 @@ public class PdaRelabelService {
|
||||
@Resource private RelabelPhotoFileGateway photoFiles;
|
||||
@Resource private ObjectMapper sourceObjectMapper;
|
||||
@Resource private com.baiyue.lastleg.mapper.LastlegOrderMapper tailOrders;
|
||||
@Resource private com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory;
|
||||
@Resource private com.baiyue.lastleg.exceptionorder.service.ExceptionOrderService exceptionOrders;
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@@ -84,14 +85,27 @@ public class PdaRelabelService {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<PdaRelabelResponses.Candidate> candidates(String operatorKey, long overseasWarehouseId) {
|
||||
return applications.selectList(new LambdaQueryWrapper<RelabelApplication>()
|
||||
List<RelabelApplication> rows = applications.selectList(new LambdaQueryWrapper<RelabelApplication>()
|
||||
.eq(RelabelApplication::getOverseasWarehouseId, String.valueOf(overseasWarehouseId))
|
||||
.in(RelabelApplication::getStatus, RelabelApplicationService.PENDING_PROCESSING, PROCESSING,
|
||||
RelabelApplicationService.CUSTOMER_CONFIRM_PENDING)
|
||||
.orderByAsc(RelabelApplication::getLabelDeadline)
|
||||
.orderByAsc(RelabelApplication::getCreatedAt))
|
||||
.stream().map(application -> candidate(application,
|
||||
tasks.find(application.getCustomerId(), application.getId()), operatorKey)).toList();
|
||||
.orderByAsc(RelabelApplication::getCreatedAt));
|
||||
List<Long> orderIds = rows.stream().map(RelabelApplication::getLastlegOrderId)
|
||||
.filter(Objects::nonNull).distinct().toList();
|
||||
Map<Long, com.baiyue.lastleg.entity.LastlegOrder> linkedOrders = orderIds.isEmpty() ? Map.of()
|
||||
: tailOrders.selectBatchIds(orderIds).stream().collect(Collectors.toMap(
|
||||
com.baiyue.lastleg.entity.LastlegOrder::getId, order -> order));
|
||||
Map<String, String> companyNames = new HashMap<>();
|
||||
return rows.stream().map(application -> {
|
||||
var order = linkedOrders.get(application.getLastlegOrderId());
|
||||
String customerName = order != null && Objects.equals(application.getCustomerId(), order.getCustomerId())
|
||||
? order.getCustomerName() : null;
|
||||
if (!StringUtils.hasText(customerName)) {
|
||||
customerName = companyNames.computeIfAbsent(application.getCustomerId(), customerId -> customerDirectory.find(customerId).name());
|
||||
}
|
||||
return candidate(application, tasks.find(application.getCustomerId(), application.getId()), operatorKey, customerName);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -458,6 +472,7 @@ public class PdaRelabelService {
|
||||
"manualCompletion", request.manualCompletion()));
|
||||
applicationService.changeStatus(application.getCustomerId(), relabelNo, operatorKey,
|
||||
new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, request.remark()));
|
||||
applicationService.settlePdaCompletion(application.getCustomerId(), relabelNo, operatorKey);
|
||||
application.setStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING);
|
||||
PdaRelabelResponses.Detail result = detail(application, task);
|
||||
saveOperation(task, operatorKey, "COMPLETE", request.idempotencyKey(), command, result);
|
||||
@@ -704,7 +719,7 @@ public class PdaRelabelService {
|
||||
}
|
||||
|
||||
private PdaRelabelResponses.Candidate candidate(RelabelApplication application, PdaRelabelTask task,
|
||||
String operatorKey) {
|
||||
String operatorKey, String customerName) {
|
||||
return new PdaRelabelResponses.Candidate(application.getRelabelNo(), application.getCustomerOrderNo(),
|
||||
application.getLabelDeadline(), application.getStatus(), effectiveTaskStatus(application, task),
|
||||
operationType(application), value(application.getItemCount()),
|
||||
@@ -713,7 +728,7 @@ public class PdaRelabelService {
|
||||
task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()),
|
||||
task != null && PROCESSING.equals(task.getStatus()) && operatorKey.equals(task.getOperatorKey()),
|
||||
task == null ? null : task.getReviewDecision(), task == null ? null : task.getReviewRemark(),
|
||||
task == null ? null : task.getReviewedAt());
|
||||
task == null ? null : task.getReviewedAt(), customerName);
|
||||
}
|
||||
|
||||
private PdaRelabelResponses.Detail detail(RelabelApplication application, PdaRelabelTask task) {
|
||||
|
||||
+9
-1
@@ -850,7 +850,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
|
||||
private void publishCompletionFeesAndSettlement(RelabelApplication application, String operatorId,
|
||||
List<LastlegOrder> linkedOrders) {
|
||||
if ("NOT_REQUIRED".equals(application.getWalletStatus())) return;
|
||||
if (Set.of("NOT_REQUIRED", "SETTLE_PENDING", "SETTLED").contains(Objects.toString(application.getWalletStatus(), ""))) return;
|
||||
BigDecimal quantity = BigDecimal.valueOf(Objects.requireNonNullElse(application.getTotalPackageCount(), 0));
|
||||
String customerId = application.getCustomerId(), relabelNo = application.getRelabelNo();
|
||||
feeSources.publishApproved(operatorId, "RELABEL:" + relabelNo,
|
||||
@@ -868,6 +868,14 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void settlePdaCompletion(String customerId, String relabelNo, String operatorId) {
|
||||
RelabelApplication application = applications.lockNo(customerId, relabelNo);
|
||||
if (application == null) throw notFound("Relabel application not found");
|
||||
if (!CUSTOMER_CONFIRM_PENDING.equals(application.getStatus())) throw conflict("换标尚未完成,不能扣款");
|
||||
publishCompletionFeesAndSettlement(application, operatorId, tailOrders(application));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public RelabelResponses.Detail confirmCompletion(String customerId, String relabelNo, String operatorId,
|
||||
RelabelRequests.CustomerConfirm request) {
|
||||
|
||||
+6
@@ -12,6 +12,12 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ReturnInventoryBalanceMapper extends BaseMapper<ReturnInventoryBalance> {
|
||||
@Update("UPDATE ll_return_inventory_balance SET total_quantity=total_quantity-#{quantity}, " +
|
||||
"available_quantity=available_quantity-#{quantity},updated_at=NOW(3) " +
|
||||
"WHERE id=#{id} AND customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} " +
|
||||
"AND total_quantity>=#{quantity} AND available_quantity>=#{quantity} AND carton_quantity>=1")
|
||||
int deductOutboundPieces(@Param("id") long id, @Param("customerId") String customerId,
|
||||
@Param("warehouseId") String warehouseId, @Param("quantity") long quantity);
|
||||
String FREE_STOCK_COLUMNS = "b.id,b.customer_id,b.overseas_warehouse_id,b.sku_code,b.product_name_zh,b.total_quantity,b.carton_quantity,b.created_at,b.updated_at," +
|
||||
"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 " +
|
||||
|
||||
+34
-9
@@ -382,6 +382,17 @@ public class ReturnInventoryService {
|
||||
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public void shipCarton(String customerId, String warehouseId, String returnNo, String cartonMark,
|
||||
String outboundNo, long outboundPickId) {
|
||||
shipCartonInternal(customerId, warehouseId, returnNo, cartonMark, outboundNo, outboundPickId, null);
|
||||
}
|
||||
|
||||
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public void shipCartonQuantity(String customerId, String warehouseId, String returnNo, String cartonMark,
|
||||
String outboundNo, long outboundPickId, long quantity) {
|
||||
shipCartonInternal(customerId, warehouseId, returnNo, cartonMark, outboundNo, outboundPickId, quantity);
|
||||
}
|
||||
|
||||
private void shipCartonInternal(String customerId, String warehouseId, String returnNo, String cartonMark,
|
||||
String outboundNo, long outboundPickId, Long requestedQuantity) {
|
||||
ReturnInventoryCarton carton = cartons.lockMark(required(cartonMark, "退货箱码不能为空"));
|
||||
if (carton == null || !customerId.equals(carton.getCustomerId())
|
||||
|| !warehouseId.equals(carton.getOverseasWarehouseId()) || !matchesReturnSource(carton, returnNo)
|
||||
@@ -400,22 +411,27 @@ public class ReturnInventoryService {
|
||||
}
|
||||
if (contents.isEmpty() || total != value(carton.getTotalQuantity())
|
||||
|| carton.getSkuTypes() == null || contents.size() != carton.getSkuTypes()) throw conflict("退货箱内容与实物数量不一致");
|
||||
if (requestedQuantity != null && (contents.size() != 1 || requestedQuantity <= 0 || requestedQuantity > total))
|
||||
throw conflict("单SKU出库件数或来源明细不匹配");
|
||||
boolean partial = requestedQuantity != null && requestedQuantity < total;
|
||||
boolean parcel = "PARCEL".equals(carton.getSourceUnitType());
|
||||
long cartonDelta = partial || parcel ? 0 : 1;
|
||||
contents = contents.stream().sorted(Comparator.comparing(ReturnInventoryCartonItem::getSkuCode)).toList();
|
||||
String scopedCustomer = carton.getCustomerId(), scopedWarehouse = carton.getOverseasWarehouseId();
|
||||
Map<String, com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryBalance> lockedBalances = new LinkedHashMap<>();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (ReturnInventoryCartonItem content : contents) {
|
||||
var balance = balances.lockSku(scopedCustomer, scopedWarehouse, content.getSkuCode());
|
||||
long quantity = content.getQuantity();
|
||||
long quantity = requestedQuantity == null ? content.getQuantity() : requestedQuantity;
|
||||
if (balance == null || value(balance.getTotalQuantity()) < quantity
|
||||
|| value(balance.getAvailableQuantity()) < quantity || value(balance.getCartonQuantity()) < 1) {
|
||||
|| value(balance.getAvailableQuantity()) < quantity || (!parcel && value(balance.getCartonQuantity()) < 1)) {
|
||||
throw conflict("退货库存不足,不能完成该箱出库:" + content.getSkuCode());
|
||||
}
|
||||
lockedBalances.put(content.getSkuCode(), balance);
|
||||
}
|
||||
for (ReturnInventoryCartonItem content : contents) {
|
||||
var balance = lockedBalances.get(content.getSkuCode());
|
||||
long quantity = content.getQuantity();
|
||||
long quantity = requestedQuantity == null ? content.getQuantity() : requestedQuantity;
|
||||
var movement = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOutboundMovement();
|
||||
movement.setId(IdWorker.getId()); movement.setOutboundPickId(outboundPickId);
|
||||
movement.setCustomerId(scopedCustomer); movement.setOverseasWarehouseId(scopedWarehouse);
|
||||
@@ -424,12 +440,19 @@ public class ReturnInventoryService {
|
||||
movement.setSkuCode(content.getSkuCode()); movement.setProductNameZh(content.getProductNameZh()); movement.setQuantity(quantity);
|
||||
movement.setTotalBefore(balance.getTotalQuantity()); movement.setTotalAfter(balance.getTotalQuantity() - quantity);
|
||||
movement.setAvailableBefore(balance.getAvailableQuantity()); movement.setAvailableAfter(balance.getAvailableQuantity() - quantity);
|
||||
movement.setCartonsBefore(balance.getCartonQuantity()); movement.setCartonsAfter(balance.getCartonQuantity() - 1);
|
||||
movement.setCartonsBefore(balance.getCartonQuantity()); movement.setCartonsAfter(balance.getCartonQuantity() - cartonDelta);
|
||||
movement.setOccurredAt(now);
|
||||
if (balances.deductOutbound(balance.getId(), scopedCustomer, scopedWarehouse, quantity) != 1) throw conflict("退货库存余额已变化,请重新核对");
|
||||
int changed = partial || parcel ? balances.deductOutboundPieces(balance.getId(), scopedCustomer, scopedWarehouse, quantity)
|
||||
: balances.deductOutbound(balance.getId(), scopedCustomer, scopedWarehouse, quantity);
|
||||
if (changed != 1) throw conflict("退货库存余额已变化,请重新核对");
|
||||
if (movements.insert(movement) != 1) throw new IllegalStateException("退货出库流水写入失败");
|
||||
if (partial) {
|
||||
content.setQuantity(content.getQuantity() - quantity);
|
||||
if (cartonItems.updateById(content) != 1) throw conflict("退货箱内数量已变化,请重新核对");
|
||||
}
|
||||
}
|
||||
carton.setStatus("OUTBOUNDED"); carton.setUpdatedAt(now);
|
||||
if (partial) carton.setTotalQuantity(total - requestedQuantity);
|
||||
carton.setStatus(partial ? "AVAILABLE" : "OUTBOUNDED"); carton.setUpdatedAt(now);
|
||||
if (cartons.updateById(carton) != 1) throw conflict("退货箱状态已变化,请重新核对");
|
||||
}
|
||||
|
||||
@@ -441,7 +464,9 @@ public class ReturnInventoryService {
|
||||
required(lines.get(0).skuCode(), "SKU 不能为空");
|
||||
String cartonMark = text(requestedMark);
|
||||
long cartonId = IdWorker.getId();
|
||||
if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId);
|
||||
// A parcel row tracks loose SKU provenance; it is never a physical carton or printable box label.
|
||||
if ("PARCEL".equals(receipt.getUnitType())) cartonMark = "SP" + hash(List.of(receipt.getId(), lines.get(0).skuCode())).substring(0, 24).toUpperCase(java.util.Locale.ROOT);
|
||||
else if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId);
|
||||
if (cartons.findMark(cartonMark) != null) throw conflict("箱号已存在: " + cartonMark);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
long total = lines.stream().mapToLong(ReceiptLine::quantity).sum();
|
||||
@@ -465,7 +490,7 @@ public class ReturnInventoryService {
|
||||
item.setSkuCode(line.skuCode()); item.setProductNameZh(text(line.productNameZh())); item.setQuantity(line.quantity());
|
||||
cartonItems.insert(item);
|
||||
balances.adjust(IdWorker.getId(), order.getCustomerId(), order.getOverseasWarehouseId(), line.skuCode(),
|
||||
text(line.productNameZh()), line.quantity(), line.quantity(), 1);
|
||||
text(line.productNameZh()), line.quantity(), line.quantity(), "PARCEL".equals(receipt.getUnitType()) ? 0 : 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();
|
||||
@@ -968,7 +993,7 @@ public class ReturnInventoryService {
|
||||
productNames.putIfAbsent(sku, item.getProductNameZh());
|
||||
item.setQuantity(item.getQuantity() - take);
|
||||
if (item.getQuantity() == 0) cartonItems.deleteById(item.getId()); else cartonItems.updateById(item);
|
||||
if (item.getQuantity() == 0) emptiedBySku.merge(sku, 1, Integer::sum);
|
||||
if (item.getQuantity() == 0 && !"PARCEL".equals(source.getSourceUnitType())) emptiedBySku.merge(sku, 1, Integer::sum);
|
||||
}
|
||||
if (item.getQuantity() > 0) { sourceTotal = Math.addExact(sourceTotal, item.getQuantity()); sourceTypes++; }
|
||||
}
|
||||
|
||||
+7
-4
@@ -255,7 +255,7 @@ public class ReturnReceiptService {
|
||||
.toList();
|
||||
long boxes = value(receipt.getTotalBoxCount());
|
||||
if (boxes == 0) boxes = lines.stream().mapToLong(ReturnOrderResponses.ReceiptItem::boxCount).sum();
|
||||
String cartonMark = text(receipt.getCartonMark());
|
||||
String cartonMark = "PARCEL".equals(receipt.getUnitType()) ? null : text(receipt.getCartonMark());
|
||||
List<ReturnInventoryService.ReturnOrderLine> labelLines = lines.stream()
|
||||
.map(line -> new ReturnInventoryService.ReturnOrderLine(line.skuCode(), line.productNameZh(), line.quantity(), line.boxCount()))
|
||||
.toList();
|
||||
@@ -579,6 +579,9 @@ public class ReturnReceiptService {
|
||||
if (unitCode == null && "PALLET".equals(unitType)) unitCode = text(request.palletCode());
|
||||
if (unitCode == null && "BOX".equals(unitType))
|
||||
unitCode = "RC" + hash(List.of(order.getReturnNo(), IdWorker.getId())).substring(0, 16).toUpperCase(java.util.Locale.ROOT) + "#1-1";
|
||||
if (unitCode == null && "PARCEL".equals(unitType)) {
|
||||
unitCode = "PK" + hash(List.of(order.getReturnNo(), IdWorker.getId())).substring(0, 16).toUpperCase(java.util.Locale.ROOT);
|
||||
}
|
||||
if (unitCode == null) throw badRequest(("PALLET".equals(unitType) ? "托盘码" : "包裹码") + "不能为空");
|
||||
if ("PALLET".equals(unitType)
|
||||
&& (request.length() == null || request.width() == null || request.height() == null || request.weight() == null
|
||||
@@ -633,7 +636,7 @@ public class ReturnReceiptService {
|
||||
} else {
|
||||
inventoryCartons = List.of(inventory.createReceiptCarton(order, receipt, unitCode, new ArrayList<>(inventoryLines.values())));
|
||||
}
|
||||
receipt.setCartonMark(inventoryCartons.get(0).getCartonMark());
|
||||
receipt.setCartonMark("PARCEL".equals(unitType) ? null : inventoryCartons.get(0).getCartonMark());
|
||||
receipts.updateById(receipt);
|
||||
syncTailCargo(order, receipt, allocations, now);
|
||||
List<ReturnOrderItem> finalItems = orderItems.listOrder(order.getId());
|
||||
@@ -777,10 +780,10 @@ public class ReturnReceiptService {
|
||||
return confirmed(order, receipt, COMPLETED.equals(order.getStatus()));
|
||||
}
|
||||
private PdaReturnReceiptResponses.Confirmed confirmed(ReturnOrder order, ReturnReceipt receipt, boolean completed) {
|
||||
String cartonMark = text(receipt.getCartonMark());
|
||||
String cartonMark = "PARCEL".equals(receipt.getUnitType()) ? null : text(receipt.getCartonMark());
|
||||
String printableHtml = null;
|
||||
List<ReturnOrderResponses.CartonLabel> labels = new java.util.ArrayList<>();
|
||||
if (receipt.getId() != null) {
|
||||
if (receipt.getId() != null && !"PARCEL".equals(receipt.getUnitType())) {
|
||||
for (ReturnInventoryCarton carton : cartons.listReceipt(receipt.getId())) {
|
||||
var label = inventory.label(order.getCustomerId(), carton.getCartonMark());
|
||||
if (label != null) labels.add(new ReturnOrderResponses.CartonLabel(carton.getCartonMark(), label.printableHtml()));
|
||||
|
||||
@@ -2043,7 +2043,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID',
|
||||
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号',
|
||||
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
|
||||
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空', volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照',
|
||||
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空', volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', return_sku_quantity BIGINT NULL COMMENT '退货单SKU本来源计划出库件数快照,NULL为历史整箱出库', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,sku_code),
|
||||
KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code)
|
||||
@@ -3079,11 +3079,11 @@ CREATE TABLE IF NOT EXISTS ll_return_inventory_outbound_movement (
|
||||
occurred_at DATETIME(3) NOT NULL COMMENT '出库记账时间',
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_return_outbound_pick_sku(outbound_pick_id,sku_code),
|
||||
UNIQUE KEY uk_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code),
|
||||
KEY idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code),
|
||||
KEY idx_ll_return_outbound_movement_query(customer_id,overseas_warehouse_id,sku_code,occurred_at),
|
||||
CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0
|
||||
AND total_before-total_after=quantity AND available_before-available_after=quantity
|
||||
AND cartons_after>=0 AND cartons_before-cartons_after=1 AND total_after>=available_after)
|
||||
AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)
|
||||
) ENGINE=InnoDB COMMENT='不可变退货出库SKU库存流水';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_balance (
|
||||
@@ -3264,6 +3264,29 @@ CREATE TABLE IF NOT EXISTS ll_return_carton_count_session (
|
||||
UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id)
|
||||
) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话';
|
||||
|
||||
-- 退货单 SKU 按件数出库快照。目标库:baiyue_lastleg。可重复执行。
|
||||
|
||||
SET @return_sku_quantity_ddl = IF(
|
||||
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_outbound_pallet_item' AND column_name='return_sku_quantity'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE ll_outbound_pallet_item ADD COLUMN return_sku_quantity BIGINT NULL COMMENT ''退货单SKU本来源计划出库件数快照,NULL为历史整箱出库'' AFTER sku_code'
|
||||
);
|
||||
PREPARE return_sku_quantity_stmt FROM @return_sku_quantity_ddl;
|
||||
EXECUTE return_sku_quantity_stmt;
|
||||
DEALLOCATE PREPARE return_sku_quantity_stmt;
|
||||
|
||||
-- 部分出库仅扣件数;整箱扣一箱,保留守恒和非负约束。
|
||||
SET @return_sku_check_ddl = IF(EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' AND constraint_name='chk_ll_return_outbound_movement_delta'), 'ALTER TABLE ll_return_inventory_outbound_movement DROP CHECK chk_ll_return_outbound_movement_delta, ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)', 'ALTER TABLE ll_return_inventory_outbound_movement ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)');
|
||||
PREPARE return_sku_check_stmt FROM @return_sku_check_ddl;
|
||||
EXECUTE return_sku_check_stmt;
|
||||
DEALLOCATE PREPARE return_sku_check_stmt;
|
||||
-- 同箱余数允许再次出库,防重仍由拣货ID+SKU唯一索引保证。
|
||||
SET @return_sku_index_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' AND index_name='uk_ll_return_outbound_carton_sku'), 'ALTER TABLE ll_return_inventory_outbound_movement DROP INDEX uk_ll_return_outbound_carton_sku, ADD INDEX idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code)', 'SELECT 1');
|
||||
PREPARE return_sku_index_stmt FROM @return_sku_index_ddl;
|
||||
EXECUTE return_sku_index_stmt;
|
||||
DEALLOCATE PREPARE return_sku_index_stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_procurement_wallet_attempt (
|
||||
id BIGINT NOT NULL COMMENT '请求尝试ID',
|
||||
order_id BIGINT NOT NULL COMMENT '采购订单ID',
|
||||
|
||||
@@ -822,6 +822,30 @@ paths:
|
||||
- { name: warehouseId, in: query, schema: { type: string } }
|
||||
- { name: status, in: query, schema: { type: string } }
|
||||
responses: { '200': { description: 出库单分页 } }
|
||||
/api/platform/v1/last-leg/outbound-orders/return:
|
||||
post:
|
||||
summary: 创建退货出库单,支持单SKU全部或部分件数
|
||||
description: 权限 platform:overseas-warehouse:outbound:create;单SKU来源须为纯SKU可用箱或散件。混合SKU箱须先拆箱。按箱唛排序分配件数,未拣货前占用整个使用来源;拣货后原箱余数恢复可用。相同幂等键改数量拒绝。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: customerId, in: query, required: true, schema: { type: string } }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [recipient, overseasWarehouseId, returnNo, cartonMarks, idempotencyKey]
|
||||
properties:
|
||||
recipient: { type: string, maxLength: 1000 }
|
||||
overseasWarehouseId: { type: string, maxLength: 128 }
|
||||
returnNo: { type: string, maxLength: 128 }
|
||||
cartonMarks: { type: array, minItems: 1, maxItems: 200, uniqueItems: true, items: { type: string, maxLength: 128 } }
|
||||
skuCode: { type: string, maxLength: 128, nullable: true }
|
||||
quantity: { type: integer, format: int64, minimum: 1, nullable: true, description: 单SKU计划件数,不得超过所选来源总数;省略出全部,只允许skuCode已填且非PALLET维度,不接受小数 }
|
||||
unitType: { type: string, enum: [BOX, PALLET], nullable: true }
|
||||
remark: { type: string, maxLength: 1000, nullable: true }
|
||||
idempotencyKey: { type: string, maxLength: 255 }
|
||||
responses: { '200': { description: 退货出库PDA任务 }, '400': { description: 数量或请求格式错误 }, '409': { description: 来源占用、库存或幂等冲突 } }
|
||||
/api/platform/v1/last-leg/outbound-orders/one-click-dropship:
|
||||
post:
|
||||
summary: 创建一件代发出库单
|
||||
|
||||
@@ -875,6 +875,8 @@ components:
|
||||
outboundPalletItemId: { type: string, pattern: '^[0-9]+$' }
|
||||
inventoryPositionId: { type: string, nullable: true, pattern: '^[0-9]+$', description: 普通库存使用真实仓位键;退货箱库存没有该仓位,提交 null,不提交空字符串或退货库存内部ID }
|
||||
packages: { type: integer, format: int64, minimum: 1 }
|
||||
productQuantity: { type: integer, format: int64, minimum: 1, nullable: true, description: 单SKU退货明细存在returnSkuQuantity时必填且必须等于计划件数;不接受小数。历史无快照明细可省略 }
|
||||
skuCode: { type: string, maxLength: 128, nullable: true, description: PARCEL散件单SKU扫码值;已装箱来源必须扫描箱唛 }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
cartonMarks:
|
||||
type: array
|
||||
@@ -911,6 +913,7 @@ components:
|
||||
outboundPalletItemId: { type: string, description: pick请求提交键,不作为用户展示编号 }
|
||||
palletNo: { type: string, nullable: true, description: Warehouse 权威实物托盘码;退货箱明细不伪造托盘码 }
|
||||
skuCode: { type: string, nullable: true }
|
||||
returnSkuQuantity: { type: integer, format: int64, nullable: true, minimum: 1, description: 本来源计划出库件数快照;部分拣货后余数保留原箱,PDA不得改变计划件数 }
|
||||
inboundCode: { type: string }
|
||||
packageCount: { type: integer }
|
||||
pickedPackageCount: { type: integer }
|
||||
|
||||
+32
@@ -236,6 +236,38 @@ class DeliveryTransferServiceTest {
|
||||
assertEquals(1, delivery.getValue().getPackageCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void partialSkuOutboundDistributesSixPiecesAcrossTwoFivePieceSources() {
|
||||
var first = returnCarton("RC-1", null); first.setId(901L); first.setTotalQuantity(5L);
|
||||
var second = returnCarton("RC-2", null); second.setId(902L); second.setTotalQuantity(5L);
|
||||
when(returnCartons.lockMark("RC-1")).thenReturn(first); when(returnCartons.lockMark("RC-2")).thenReturn(second);
|
||||
var line = new ReturnInventoryCartonItem(); line.setSkuCode("SKU-A"); line.setQuantity(5L);
|
||||
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(line));
|
||||
when(returnCartonItems.lockCarton(902L)).thenReturn(List.of(line));
|
||||
service.createReturnOutbound("TENANT-A", "U-1", new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1",
|
||||
List.of("RC-1", "RC-2"), null, "partial-six", "SKU-A", "BOX", 6L));
|
||||
var capture = ArgumentCaptor.forClass(OutboundPalletItem.class);
|
||||
verify(outboundPallets, org.mockito.Mockito.times(2)).insert(capture.capture());
|
||||
assertEquals(List.of(5L, 1L), capture.getAllValues().stream().map(OutboundPalletItem::getReturnSkuQuantity).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void singleSkuReturnOutboundAcceptsPackedSingleSkuButKeepsCartonScanning() {
|
||||
ReturnInventoryCarton carton = returnCarton("RC-BOX", "PAL-1");
|
||||
carton.setSourceUnitType("BOX"); carton.setTotalQuantity(5L);
|
||||
ReturnInventoryCartonItem line = new ReturnInventoryCartonItem();
|
||||
line.setSkuCode("SKU-A"); line.setQuantity(5L);
|
||||
when(returnCartons.lockMark("RC-BOX")).thenReturn(carton);
|
||||
when(returnCartonItems.lockCarton(carton.getId())).thenReturn(List.of(line));
|
||||
service.createReturnOutbound("TENANT-A", "U-1",
|
||||
new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("RC-BOX"), null, "packed-sku", "SKU-A"));
|
||||
ArgumentCaptor<OutboundPalletItem> item = ArgumentCaptor.forClass(OutboundPalletItem.class);
|
||||
verify(outboundPallets).insert(item.capture());
|
||||
assertNull(item.getValue().getSkuCode());
|
||||
assertEquals("RC-BOX", item.getValue().getCartonMarks());
|
||||
assertEquals("OUTBOUND_PENDING", carton.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void singleSkuReturnOutboundRejectsMixedCarton() {
|
||||
ReturnInventoryCarton mixed = returnCarton("RC-MIXED", null);
|
||||
|
||||
+11
-2
@@ -71,9 +71,18 @@ class ReturnOutboundClosureTest {
|
||||
verify(outbounds, times(1)).insert(any(OutboundOrder.class));
|
||||
}
|
||||
|
||||
@Test void preventsSingleSkuBypassingExistingBox() {
|
||||
@Test void singleSkuPackedBoxKeepsCartonScanAndPieceSnapshot() {
|
||||
service.createReturnOutbound("C1", "USER", command("SKU1"));
|
||||
verify(items).insert(argThat((OutboundPalletItem i) -> i.getSkuCode() == null && Long.valueOf(6L).equals(i.getReturnSkuQuantity())));
|
||||
assertThat(carton.getStatus()).isEqualTo("OUTBOUND_PENDING");
|
||||
}
|
||||
|
||||
@Test void singleSkuCannotConsumeMixedSkuCarton() {
|
||||
ReturnInventoryCartonItem other = new ReturnInventoryCartonItem(); other.setSkuCode("SKU2"); other.setQuantity(1L);
|
||||
var first = contents.lockCarton(10L).get(0);
|
||||
when(contents.lockCarton(10L)).thenReturn(List.of(first, other));
|
||||
assertThatThrownBy(() -> service.createReturnOutbound("C1", "USER", command("SKU1")))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("按箱唛");
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("SKU");
|
||||
verify(outbounds, never()).insert(any(OutboundOrder.class));
|
||||
}
|
||||
|
||||
|
||||
+18
@@ -82,6 +82,24 @@ class PdaRelabelServiceTest {
|
||||
lenient().when(tasks.updateWithVersion(any(), anyInt())).thenReturn(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void candidatesUseCustomerNameOnlyFromMatchingCustomerOrder() {
|
||||
var orders = org.mockito.Mockito.mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);
|
||||
var directory = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class);
|
||||
ReflectionTestUtils.setField(service, "customerDirectory", directory);
|
||||
ReflectionTestUtils.setField(service, "tailOrders", orders);
|
||||
application.setLastlegOrderId(20L);
|
||||
var order = new com.baiyue.lastleg.entity.LastlegOrder();
|
||||
order.setId(20L); order.setCustomerId("100"); order.setCustomerName("测试客户");
|
||||
when(applications.selectList(any())).thenReturn(List.of(application));
|
||||
when(orders.selectBatchIds(any())).thenReturn(List.of(order));
|
||||
assertEquals("测试客户", service.candidates("7", 900L).get(0).customerName());
|
||||
order.setCustomerId("OTHER");
|
||||
when(directory.find("100")).thenReturn(new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer("100", "C100", "所属公司", null, null));
|
||||
assertEquals("所属公司", service.candidates("7", 900L).get(0).customerName());
|
||||
verify(orders, org.mockito.Mockito.times(2)).selectBatchIds(List.of(20L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void claimUsesFrozenWarehouseAndAdvancesExistingApplicationStateMachine() {
|
||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
|
||||
+15
@@ -54,6 +54,7 @@ import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
@@ -643,6 +644,18 @@ class RelabelApplicationServiceTest {
|
||||
assertEquals(6L, move.getValue().packages());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pdaCompletionSettlesBeforeCustomerConfirmationAndDoesNotSettleTwice() {
|
||||
var app = confirmationApplication("TRUCK", false);
|
||||
when(applications.lockNo("10001", "FBR-1")).thenReturn(app);
|
||||
when(orders.selectById(21L)).thenReturn(order(21L, "10001", "LMO-1", "WAIT_RELABEL"));
|
||||
service.settlePdaCompletion("10001", "FBR-1", "picker");
|
||||
service.settlePdaCompletion("10001", "FBR-1", "picker");
|
||||
verify(walletCommands).publish(eq("SETTLE"), eq(10001L), eq("RELABEL_ORDER"), eq("FBR-1"), isNull(), anyString(), eq(2L));
|
||||
assertEquals("SETTLE_PENDING", app.getWalletStatus());
|
||||
assertNull(app.getCustomerConfirmedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void truckCustomerConfirmationIsIdempotentAndWaitsForPreDispatch() {
|
||||
RelabelApplication application = confirmationApplication("TRUCK", false);
|
||||
@@ -662,6 +675,7 @@ class RelabelApplicationServiceTest {
|
||||
assertEquals("CONFIRMED", review.getValue().getReviewDecision());
|
||||
assertEquals("WAIT_OUTBOUND", linked.getCurrentStatus());
|
||||
assertNull(application.getOutboundOrderNo());
|
||||
verify(walletCommands, never()).publish(eq("FREEZE"), anyLong(), anyString(), anyString(), any(), anyString(), anyLong());
|
||||
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
|
||||
|
||||
service.confirmCompletion("10001", "FBR-1", "customer-1",
|
||||
@@ -838,6 +852,7 @@ class RelabelApplicationServiceTest {
|
||||
RelabelApplication application = application(11L, "FBR-1", "10001", "key-1", "hash",
|
||||
RelabelApplicationService.CUSTOMER_CONFIRM_PENDING);
|
||||
application.setLastlegOrderId(21L);
|
||||
application.setOverseasWarehouseId("900");
|
||||
application.setDeliveryMethod(deliveryMethod);
|
||||
application.setPrintShippingLabel(printShippingLabel);
|
||||
return application;
|
||||
|
||||
+38
-2
@@ -30,6 +30,7 @@ import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -75,6 +76,30 @@ class ReturnInventoryServiceTest {
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
|
||||
}
|
||||
|
||||
@Test
|
||||
void partialSkuShipmentKeepsOriginalCartonAndDoesNotSubtractCartonCount() {
|
||||
var carton = new ReturnInventoryCarton(); carton.setId(31L); carton.setCartonMark("RC-1");
|
||||
carton.setCustomerId("10001"); carton.setOverseasWarehouseId("42"); carton.setSourceReturnNo("RT-1");
|
||||
carton.setStatus("OUTBOUND_PENDING"); carton.setCountStatus("CONFIRMED"); carton.setSkuTypes(1); carton.setTotalQuantity(5L);
|
||||
var line = item(32L, "SKU-A", 5);
|
||||
var balance = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryBalance();
|
||||
balance.setId(41L); balance.setTotalQuantity(5L); balance.setAvailableQuantity(5L); balance.setCartonQuantity(1L);
|
||||
when(cartons.lockMark("RC-1")).thenReturn(carton);
|
||||
when(cartonItems.lockCarton(31L)).thenReturn(List.of(line));
|
||||
when(balances.lockSku("10001", "42", "SKU-A")).thenReturn(balance);
|
||||
when(balances.deductOutboundPieces(41L, "10001", "42", 2L)).thenReturn(1);
|
||||
when(movements.insert(any(com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOutboundMovement.class))).thenReturn(1);
|
||||
when(cartonItems.updateById(line)).thenReturn(1); when(cartons.updateById(carton)).thenReturn(1);
|
||||
service.shipCartonQuantity("10001", "42", "RT-1", "RC-1", "OB-1", 51L, 2L);
|
||||
assertThat(carton.getStatus()).isEqualTo("AVAILABLE"); assertThat(carton.getTotalQuantity()).isEqualTo(3L);
|
||||
assertThat(line.getQuantity()).isEqualTo(3L);
|
||||
var capture = org.mockito.ArgumentCaptor.forClass(com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOutboundMovement.class);
|
||||
verify(movements).insert(capture.capture());
|
||||
assertThat(capture.getValue().getQuantity()).isEqualTo(2L);
|
||||
assertThat(capture.getValue().getCartonsAfter()).isEqualTo(1L);
|
||||
verify(balances, never()).deductOutbound(anyLong(), anyString(), anyString(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformPlanNumberFilterIsTrimmedAndUsedForCountAndPage() {
|
||||
when(packingPlans.countPlatform(null, "PLAN-1", "C1", "W1", "SKU1")).thenReturn(1L);
|
||||
@@ -124,6 +149,17 @@ class ReturnInventoryServiceTest {
|
||||
assertThat(capture.getValue().getResultJson()).contains("RC-BOX", "RR-BOX", "商品A", "\"quantity\":3", "\"quantity\":7");
|
||||
}
|
||||
|
||||
@Test
|
||||
void parcelSigningStoresLooseSkuSourceWithoutGeneratingCartonNumberOrIncreasingBoxCount() {
|
||||
var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setUnitType("PARCEL"); receipt.setUnitCode("PK-1");
|
||||
var source = service.createReceiptCarton(completedOrder(), receipt, null,
|
||||
List.of(new ReturnInventoryService.ReceiptLine("A", "商品A", 3)));
|
||||
assertThat(source.getCartonMark()).startsWith("SP");
|
||||
assertThat(source.getSourceUnitType()).isEqualTo("PARCEL");
|
||||
verifyNoInteractions(numbers, operations);
|
||||
verify(balances).adjust(anyLong(), anyString(), anyString(), eq("A"), eq("商品A"), eq(3L), eq(3L), eq(0L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cartonDetailsExposeImmutableMergeHistoryWithinCustomerScope() {
|
||||
ReturnInventoryCarton carton = new ReturnInventoryCarton();
|
||||
@@ -320,7 +356,7 @@ class ReturnInventoryServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void fullyConsumedSourceIsMergedAndSkuCartonCountRemainsOne() {
|
||||
void fullyConsumedParcelSourceIsMergedAndCreatesOnePhysicalCarton() {
|
||||
ReturnInventoryCarton locked = prepareConcurrentSource();
|
||||
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L)));
|
||||
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET");
|
||||
@@ -330,7 +366,7 @@ class ReturnInventoryServiceTest {
|
||||
assertThat(locked.getSkuTypes()).isZero();
|
||||
assertThat(locked.getStatus()).isEqualTo(ReturnInventoryService.MERGED);
|
||||
verify(cartonItems).deleteById(41L);
|
||||
verify(balances).adjustCartonQuantity("10001", "42", "SKU-A", 0L);
|
||||
verify(balances).adjustCartonQuantity("10001", "42", "SKU-A", 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+24
@@ -433,6 +433,30 @@ class ReturnReceiptServiceTest {
|
||||
verify(receipts, times(1)).insert(any(ReturnReceipt.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parcelWithoutTrackingGeneratesParcelCodeAndNeverReturnsBoxLabelsOnReplay() {
|
||||
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_RECEIPT, 3, 0);
|
||||
ReturnOrderItem line = item(11L, "SKU-A", 3, 0);
|
||||
stubNewReceipt(order, List.of(line), "SP-SOURCE");
|
||||
var request = new PdaReturnReceiptRequests.Confirm("RT-1", null, "SKU", null,
|
||||
List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)), "parcel-key",
|
||||
"PARCEL", null, null, null, null, null, null);
|
||||
var result = service.confirm(identity, request);
|
||||
var capture = ArgumentCaptor.forClass(ReturnReceipt.class);
|
||||
verify(receipts).insert(capture.capture());
|
||||
assertThat(result.unitCode()).startsWith("PK");
|
||||
assertThat(result.cartonMark()).isNull();
|
||||
assertThat(result.printableHtml()).isNull();
|
||||
assertThat(result.labels()).isEmpty();
|
||||
assertThat(capture.getValue().getTrackingNo()).isNull();
|
||||
when(receipts.lockKey("operator-1", "parcel-key")).thenReturn(capture.getValue());
|
||||
var replay = service.confirm(identity, request);
|
||||
assertThat(replay.unitCode()).isEqualTo(result.unitCode());
|
||||
assertThat(replay.labels()).isEmpty();
|
||||
verify(inventory, never()).label(anyString(), anyString());
|
||||
verify(inventory, times(1)).createReceiptCarton(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOverReceiptBeforeCreatingReceiptInventoryOrBatch() {
|
||||
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PARTIALLY_RECEIVED, 2, 1);
|
||||
|
||||
Reference in New Issue
Block a user