Merge branch 'test' into dev
This commit is contained in:
@@ -27,7 +27,7 @@
|
||||
|---|---|
|
||||
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
|
||||
| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
|
||||
| 尾程订单完整计划、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
|
||||
| 尾程订单完整计划、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;一件代发人工计划托数必须严格等于不可变头程封托托盘数,禁止保存可增减托数但无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
|
||||
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
|
||||
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 |
|
||||
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
|
||||
|
||||
+4
@@ -75,6 +75,10 @@ public interface InboundPlanOrderMapper {
|
||||
String findSourceContainerBookingId(@Param("customerId") String customerId,
|
||||
@Param("orderId") Long orderId);
|
||||
|
||||
@Select("SELECT order_type FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
|
||||
String findOrderType(@Param("customerId") String customerId,
|
||||
@Param("orderId") Long orderId);
|
||||
|
||||
@Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.container_no AS containerNo," +
|
||||
"o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," +
|
||||
"COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," +
|
||||
|
||||
+21
-4
@@ -130,10 +130,23 @@ public class InboundPlanService {
|
||||
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
|
||||
requiredReceivingWarehouse(containerWarehouseId);
|
||||
validateOrders(snapshots);
|
||||
int minimumPalletCount = suggestedPalletCount(order);
|
||||
int sourcePalletCount = Objects.requireNonNullElse(
|
||||
sourcePalletMapper.palletNos(customerId, request.orderId()), List.<String>of()).size();
|
||||
minimumPalletCount = Math.max(minimumPalletCount, sourcePalletCount);
|
||||
String orderType = trimToNull(orderMapper.findOrderType(customerId, request.orderId()));
|
||||
int sourcePalletCount = "ONE_CLICK_DROPSHIP".equals(orderType)
|
||||
? dropshipOrders.palletSnapshots(customerId, request.orderId()).size()
|
||||
: Objects.requireNonNullElse(
|
||||
sourcePalletMapper.palletNos(customerId, request.orderId()), List.<String>of()).size();
|
||||
if ("ONE_CLICK_DROPSHIP".equals(orderType)) {
|
||||
if (sourcePalletCount <= 0) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"一件代发订单缺少不可变来源托盘快照,不能生成入库计划");
|
||||
}
|
||||
if (request.palletCount() != sourcePalletCount) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"一件代发人工计划托数必须与头程封托托盘数一致:" + sourcePalletCount);
|
||||
}
|
||||
}
|
||||
int minimumPalletCount = "ONE_CLICK_DROPSHIP".equals(orderType)
|
||||
? sourcePalletCount : Math.max(suggestedPalletCount(order), sourcePalletCount);
|
||||
if (request.palletCount() < minimumPalletCount) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"人工计划托数不能少于柜级预览建议托数 " + minimumPalletCount);
|
||||
@@ -272,6 +285,10 @@ public class InboundPlanService {
|
||||
int targetPalletCount = Objects.requireNonNullElse(row.plannedPalletCount(),
|
||||
Objects.requireNonNullElse(suggested, 0));
|
||||
int supplementPalletCount = Math.max(0, targetPalletCount - sourcePalletCount);
|
||||
if (planned && "ONE_CLICK_DROPSHIP".equals(row.orderType())
|
||||
&& targetPalletCount != sourcePalletCount) {
|
||||
blockers.add("DROPSHIP_PALLET_COUNT_MISMATCH");
|
||||
}
|
||||
if (!planned && (suggested == null || suggested <= 0)) blockers.add("PALLET_COUNT_NOT_READY");
|
||||
String destinationType = destinationType(row);
|
||||
String fbaWarehouseCode = isFbaDestination(row) ? trimToNull(row.fbaWarehouseCode()) : null;
|
||||
|
||||
+3
-3
@@ -71,8 +71,8 @@ public class ReturnInventoryService {
|
||||
long total = lines.stream().mapToLong(ReceiptLine::quantity).sum();
|
||||
ReturnInventoryCarton carton = new ReturnInventoryCarton();
|
||||
carton.setId(cartonId); carton.setCustomerId(order.getCustomerId()); carton.setOverseasWarehouseId(order.getOverseasWarehouseId());
|
||||
carton.setSourceReceiptId(receipt.getId()); carton.setSourceBatchNo(receipt.getReceiptBatchNo()); carton.setCartonMark(cartonMark);
|
||||
carton.setSourceReturnNo(order.getReturnNo());
|
||||
carton.setSourceReceiptId(receipt.getId()); carton.setSourceBatchNo(receipt.getReceiptBatchNo());
|
||||
carton.setSourceReturnNo(order.getReturnNo()); carton.setCartonMark(cartonMark);
|
||||
carton.setStatus(AVAILABLE); carton.setSkuTypes(lines.size()); carton.setTotalQuantity(total); carton.setCreatedAt(now); carton.setUpdatedAt(now);
|
||||
cartons.insert(carton);
|
||||
for (ReceiptLine line : lines) {
|
||||
@@ -253,9 +253,9 @@ public class ReturnInventoryService {
|
||||
}
|
||||
Map<String, Long> remaining = new LinkedHashMap<>(requested);
|
||||
Map<String, Integer> emptiedBySku = new LinkedHashMap<>();
|
||||
Map<Long, Map<String, Long>> attachedByOrder = new LinkedHashMap<>();
|
||||
LinkedHashSet<String> consumedBatchNos = new LinkedHashSet<>();
|
||||
LinkedHashSet<String> consumedReturnNos = new LinkedHashSet<>();
|
||||
Map<Long, Map<String, Long>> attachedByOrder = new LinkedHashMap<>();
|
||||
long total = 0;
|
||||
for (ReturnInventoryCarton source : sources) {
|
||||
List<ReturnInventoryCartonItem> sourceItems = cartonItems.listCarton(source.getId());
|
||||
|
||||
@@ -87,11 +87,11 @@ public class LastlegOrderService {
|
||||
Page<LastlegOrder> page = orderMapper.selectPage(
|
||||
new Page<>(query.page(), query.size()), filter(customerId, query, relabelNo).orderByDesc(LastlegOrder::getCreatedAt));
|
||||
Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> mergeRelations = palletMergeRelations(page.getRecords());
|
||||
Map<Long, String> dispatchStatuses = dispatchStatuses(page.getRecords());
|
||||
Map<String, String> containerNodes = containerNodes(page.getRecords());
|
||||
Map<Long, String> dispatchStatuses = dispatchStatuses(page.getRecords());
|
||||
return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()),
|
||||
StringUtils.hasText(order.getSourceContainerBookingId()) ? containerNodes.get(order.getSourceContainerBookingId()) : null,
|
||||
dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"))).toList(),
|
||||
StringUtils.hasText(order.getSourceContainerBookingId())
|
||||
? containerNodes.get(order.getSourceContainerBookingId()) : null, dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"))).toList(),
|
||||
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
|
||||
}
|
||||
|
||||
@@ -581,7 +581,7 @@ public class LastlegOrderService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private Map<Long, String> dispatchStatuses(List<LastlegOrder> orders) {
|
||||
private Map<Long, String> dispatchStatuses(List<LastlegOrder> orders) {
|
||||
if (orders.isEmpty()) return Map.of();
|
||||
List<LastlegOrderMapper.DispatchStatusRow> rows = orderMapper.findDispatchStatuses(
|
||||
orders.stream().map(LastlegOrder::getId).toList());
|
||||
@@ -591,14 +591,13 @@ public class LastlegOrderService {
|
||||
}
|
||||
|
||||
private LastlegOrderSummary summary(LastlegOrder o, List<LastlegOrderMapper.PalletMergeRelationRow> relations,
|
||||
String containerNode, String dispatchStatus) {
|
||||
String containerNode, String dispatchStatus) {
|
||||
List<LastlegOrderMapper.PalletMergeRelationRow> related = relations == null ? List.of() : relations;
|
||||
return new LastlegOrderSummary(o.getId(), displayOrderNo(o), o.getOrderType(),
|
||||
LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(),
|
||||
displayInboundCode(o), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(),
|
||||
o.getOverseasWarehouseCode(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
|
||||
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(),
|
||||
containerNode,
|
||||
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), containerNode,
|
||||
o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(),
|
||||
Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER",
|
||||
o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
|
||||
|
||||
+52
-8
@@ -234,6 +234,8 @@ public class LoadingCompletedConsumer {
|
||||
String destinationType = destinationType(payload, source);
|
||||
String customerId = owner.customerId();
|
||||
String customerName = owner.customerName();
|
||||
List<FirstLegLoadingPlanCompletedV1.Item> items = "ONE_CLICK_DROPSHIP".equals(orderType)
|
||||
? canonicalItems(source.items()) : source.items();
|
||||
List<DropshipRequests.Pallet> dropshipPallets = "ONE_CLICK_DROPSHIP".equals(orderType)
|
||||
? pallets(source, rawSource) : List.of();
|
||||
if ("ONE_CLICK_DROPSHIP".equals(orderType) && dropshipPallets.isEmpty()) {
|
||||
@@ -259,7 +261,7 @@ public class LoadingCompletedConsumer {
|
||||
? dropshipPallets.size() : Objects.requireNonNullElse(source.palletCount(), 0));
|
||||
order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm());
|
||||
order.setEstimatedWarehouseAt(parsePlannedArrival(payload.estimatedWarehouseArrivalAt()));
|
||||
order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg)
|
||||
order.setVolumeWeightKg(items.stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg)
|
||||
.filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add));
|
||||
order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId());
|
||||
order.setSourceLoadingPlanId(payload.loadingPlanId());
|
||||
@@ -293,7 +295,7 @@ public class LoadingCompletedConsumer {
|
||||
throw new IllegalArgumentException("散货来源托盘码数量与托盘数不一致");
|
||||
}
|
||||
}
|
||||
for(var item:source.items()) {
|
||||
for(var item:items) {
|
||||
LastlegOrderDetail detail=new LastlegOrderDetail();
|
||||
detail.setCustomerId(customerId);
|
||||
detail.setOrderId(order.getId()); detail.setSourceInboundDetailId(item.sourceInboundDetailId()); detail.setSkuCode(item.skuCode());
|
||||
@@ -377,11 +379,8 @@ public class LoadingCompletedConsumer {
|
||||
|
||||
private Map<String, List<String>> dropshipReceivingLabels(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) {
|
||||
Map<String, FirstLegLoadingPlanCompletedV1.Item> itemsByDetail = new java.util.LinkedHashMap<>();
|
||||
for (var item : source.items()) {
|
||||
if (item.sourceInboundDetailId() == null || item.sourceInboundDetailId().isBlank()
|
||||
|| itemsByDetail.putIfAbsent(item.sourceInboundDetailId(), item) != null) {
|
||||
throw new IllegalArgumentException("一件代发来源明细标识缺失或重复");
|
||||
}
|
||||
for (var item : canonicalItems(source.items())) {
|
||||
itemsByDetail.put(item.sourceInboundDetailId(), item);
|
||||
}
|
||||
Map<String, List<String>> marksByDetail = new java.util.LinkedHashMap<>();
|
||||
for (JsonNode pallet : rawSource.path("pallets")) {
|
||||
@@ -395,7 +394,7 @@ public class LoadingCompletedConsumer {
|
||||
sku.path("cartonMarks").forEach(mark -> marks.add(mark.asText()));
|
||||
}
|
||||
}
|
||||
for (var item : source.items()) {
|
||||
for (var item : itemsByDetail.values()) {
|
||||
if (marksByDetail.getOrDefault(item.sourceInboundDetailId(), List.of()).size() != item.packageCount()) {
|
||||
throw new IllegalArgumentException("一件代发托盘箱唛与来源明细箱数不一致");
|
||||
}
|
||||
@@ -403,6 +402,51 @@ public class LoadingCompletedConsumer {
|
||||
return marksByDetail;
|
||||
}
|
||||
|
||||
/** 接受同一来源明细因多托装柜形成的多个分片,并在尾程落库前合并为唯一明细。 */
|
||||
private List<FirstLegLoadingPlanCompletedV1.Item> canonicalItems(List<FirstLegLoadingPlanCompletedV1.Item> source) {
|
||||
Map<String, List<FirstLegLoadingPlanCompletedV1.Item>> byDetail = new java.util.LinkedHashMap<>();
|
||||
for (var item : source == null ? List.<FirstLegLoadingPlanCompletedV1.Item>of() : source) {
|
||||
if (item.sourceInboundDetailId() == null || item.sourceInboundDetailId().isBlank()) {
|
||||
throw new IllegalArgumentException("一件代发来源明细标识缺失");
|
||||
}
|
||||
byDetail.computeIfAbsent(item.sourceInboundDetailId(), ignored -> new ArrayList<>()).add(item);
|
||||
}
|
||||
List<FirstLegLoadingPlanCompletedV1.Item> result = new ArrayList<>();
|
||||
for (List<FirstLegLoadingPlanCompletedV1.Item> fragments : byDetail.values()) {
|
||||
var first = fragments.get(0);
|
||||
if (fragments.stream().anyMatch(item -> !Objects.equals(first.skuCode(), item.skuCode())
|
||||
|| !Objects.equals(first.destinationWarehouseId(), item.destinationWarehouseId())
|
||||
|| !Objects.equals(first.destinationWarehouseCode(), item.destinationWarehouseCode()))) {
|
||||
throw new IllegalArgumentException("同一来源明细的尾程事件分片快照不一致");
|
||||
}
|
||||
List<String> marks = fragments.stream().flatMap(item -> item.cartonMarks().stream()).toList();
|
||||
result.add(new FirstLegLoadingPlanCompletedV1.Item(first.sourceInboundDetailId(), first.skuCode(),
|
||||
first.productNameZh(), first.productNameEn(),
|
||||
fragments.stream().mapToInt(FirstLegLoadingPlanCompletedV1.Item::packageCount).sum(),
|
||||
sumNullableInt(fragments, FirstLegLoadingPlanCompletedV1.Item::productQuantity),
|
||||
sumNullableInt(fragments, FirstLegLoadingPlanCompletedV1.Item::palletCount),
|
||||
sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::grossWeightKg),
|
||||
sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::netWeightKg),
|
||||
sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg),
|
||||
sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::volumeCbm),
|
||||
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
|
||||
first.fbaTrackingNo(), first.shipmentNo(), first.shippingMark(), first.palletNo(), marks));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private static Integer sumNullableInt(List<FirstLegLoadingPlanCompletedV1.Item> values,
|
||||
java.util.function.Function<FirstLegLoadingPlanCompletedV1.Item, Integer> getter) {
|
||||
List<Integer> present = values.stream().map(getter).filter(Objects::nonNull).toList();
|
||||
return present.isEmpty() ? null : present.stream().mapToInt(Integer::intValue).sum();
|
||||
}
|
||||
|
||||
private static BigDecimal sumDecimal(List<FirstLegLoadingPlanCompletedV1.Item> values,
|
||||
java.util.function.Function<FirstLegLoadingPlanCompletedV1.Item, BigDecimal> getter) {
|
||||
List<BigDecimal> present = values.stream().map(getter).filter(Objects::nonNull).toList();
|
||||
return present.isEmpty() ? null : present.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
|
||||
private void createCartonLabels(String customerId, Long orderId, String destinationType,
|
||||
FirstLegLoadingPlanCompletedV1.Item item, List<String> sourceMarks) {
|
||||
if (!"OVERSEAS".equals(destinationType)) return;
|
||||
|
||||
+1
@@ -52,6 +52,7 @@ public final class WarehouseTransferRequests {
|
||||
}
|
||||
}
|
||||
|
||||
/** 审核通过备注选填;驳回必须提供非空原因,由服务在任何状态写入前校验。 */
|
||||
public record Review(@NotBlank @Size(max = 32) String decision,
|
||||
@Size(max = 1000) String remark) {}
|
||||
|
||||
|
||||
+5
-1
@@ -358,6 +358,9 @@ public class WarehouseTransferService {
|
||||
case "REJECT", REJECTED -> REJECTED;
|
||||
default -> throw badRequest("Review decision must be APPROVE or REJECT");
|
||||
};
|
||||
if (REJECTED.equals(next) && !StringUtils.hasText(request.remark())) {
|
||||
throw badRequest("驳回原因不能为空");
|
||||
}
|
||||
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
|
||||
if (transfer == null) throw notFound("Warehouse transfer order not found");
|
||||
if (!PENDING_REVIEW.equals(transfer.getStatus())) {
|
||||
@@ -384,7 +387,8 @@ public class WarehouseTransferService {
|
||||
order.setUpdatedAt(now); orders.updateById(order);
|
||||
if (!java.util.Objects.equals(before, orderStatus)) {
|
||||
insertOrderHistory(customerId, order.getId(), before, orderStatus, transferNo,
|
||||
"Warehouse transfer reviewed: " + next, now);
|
||||
"Warehouse transfer reviewed: " + next
|
||||
+ (StringUtils.hasText(request.remark()) ? "; " + request.remark().trim() : ""), now);
|
||||
}
|
||||
return detail(customerId, transferNo);
|
||||
}
|
||||
|
||||
@@ -1708,6 +1708,9 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间'' AFTER customer_confirmation_key')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_relabel_item (
|
||||
return_carton_id BIGINT NULL COMMENT '退货换箱唛来源退货库存箱ID',
|
||||
source_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛原箱唛快照',
|
||||
new_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛新箱唛',
|
||||
id BIGINT NOT NULL COMMENT '主键ID',
|
||||
application_id BIGINT NOT NULL COMMENT '申请单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
@@ -2232,6 +2235,7 @@ ALTER TABLE ll_security_projection
|
||||
-- 空库基线固定管理员必须携带当前海外授权快照;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。
|
||||
SET @platform_admin_overseas_roles = JSON_ARRAY('PLATFORM_ADMIN');
|
||||
SET @platform_admin_overseas_authorities = JSON_ARRAY(
|
||||
'platform:overseas-warehouse:warehouse-transfer:approve',
|
||||
'platform:finance:bill:adjust',
|
||||
'platform:finance:bill:update',
|
||||
'platform:finance:bill:void',
|
||||
@@ -2488,6 +2492,7 @@ CREATE TABLE IF NOT EXISTS ll_return_receipt_batch (
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN carton_mark VARCHAR(128) NULL COMMENT ''签收生成或绑定的退货箱号'' AFTER receipt_batch_no'));
|
||||
PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_stmt;
|
||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
|
||||
new_carton_mark VARCHAR(255) NULL COMMENT '换标后新箱唛',
|
||||
id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'))
|
||||
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
|
||||
|
||||
+8
-10
@@ -233,11 +233,9 @@ class DispatchPdaWorkflowServiceTest {
|
||||
order.setVehiclePlate("CA-100");
|
||||
DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED");
|
||||
DispatchItem item = item(21L, "100", 6);
|
||||
DispatchPdaPick pick = new DispatchPdaPick();
|
||||
pick.setPackages(6L);
|
||||
DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(6L);
|
||||
OutboundOrder outbound = new OutboundOrder();
|
||||
outbound.setOutboundNo("OB-1");
|
||||
outbound.setStatus("WAIT_OUTBOUND");
|
||||
outbound.setOutboundNo("OB-1"); outbound.setStatus("WAIT_OUTBOUND");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound);
|
||||
@@ -245,17 +243,17 @@ class DispatchPdaWorkflowServiceTest {
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
|
||||
when(picks.totalItem("100", 11L, 21L)).thenReturn(6L);
|
||||
when(picks.listTask("100", 11L)).thenReturn(List.of(pick));
|
||||
DispatchPdaResponses.Departed result = service.depart("WH-1", "DSP-1", "7",
|
||||
new DispatchPdaRequests.Depart("depart-success", null));
|
||||
when(palletLocks.selectList(any())).thenReturn(List.of());
|
||||
|
||||
DispatchPdaResponses.Departed result = service.depart(
|
||||
"WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null));
|
||||
|
||||
assertEquals("DEPARTED", result.status());
|
||||
assertEquals("DEPARTED", order.getStatus());
|
||||
assertTrue(order.getDepartAt() != null);
|
||||
assertEquals("OUTBOUNDED", outbound.getStatus());
|
||||
when(palletLocks.selectList(any())).thenReturn(List.of());
|
||||
assertNotNull(order.getDepartAt());
|
||||
assertEquals(order.getDepartAt(), order.getUpdatedAt());
|
||||
verify(orders).updateById(order);
|
||||
assertEquals("DEPARTED", order.getStatus());
|
||||
assertEquals("OUTBOUNDED", outbound.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+19
@@ -120,6 +120,25 @@ class ContainerInboundPlanPreviewTest {
|
||||
assertEquals("RESOURCE_PENDING", result.groups().get(0).orders().get(0).readiness());
|
||||
}
|
||||
|
||||
@Test
|
||||
void blocksExistingDropshipPlanWhosePalletCountDiffersFromFrozenSourcePallets() {
|
||||
when(orderMapper.containerPreviewOrders("BOOKING-DROPSHIP-MISMATCH")).thenReturn(List.of(
|
||||
row(1L, "LM-DROPSHIP", "ONE_CLICK_DROPSHIP", "OVERSEAS", "US-LAX-01", "洛杉矶仓", 2,
|
||||
"IP-DROPSHIP", 2, 1, "PENDING_ASSIGNMENT", 0)
|
||||
));
|
||||
when(dropshipOrders.palletSnapshots("100", 1L)).thenReturn(List.of(
|
||||
new DropshipPalletSnapshot("DP-260914-0001", 10, List.of())));
|
||||
|
||||
var result = service.previewContainer("BOOKING-DROPSHIP-MISMATCH");
|
||||
var order = result.groups().get(0).orders().get(0);
|
||||
|
||||
assertFalse(result.ready());
|
||||
assertEquals("BLOCKED", order.readiness());
|
||||
assertEquals(List.of("DROPSHIP_PALLET_COUNT_MISMATCH"), order.blockers());
|
||||
assertEquals(1, order.sourcePalletCount());
|
||||
assertEquals(2, order.targetPalletCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesHeadLegExistingSupplementAndFinalPalletCountsWithoutBlocking() {
|
||||
when(orderMapper.containerPreviewOrders("BOOKING-SUPPLEMENT")).thenReturn(List.of(
|
||||
|
||||
+37
@@ -183,6 +183,43 @@ class InboundPlanServiceTest {
|
||||
verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneClickDropshipManualPlanMustKeepTheFrozenSourcePalletCount() {
|
||||
SourceOrderSnapshot source = order(2L, "RK-2", "3.3650", "WH-A");
|
||||
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source));
|
||||
when(orderMapper.findOrderType("100", 2L)).thenReturn("ONE_CLICK_DROPSHIP");
|
||||
when(dropshipOrders.palletSnapshots("100", 2L)).thenReturn(List.of(
|
||||
new DropshipPalletSnapshot("DP-260914-0001", 10L, List.of())));
|
||||
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual(
|
||||
new ManualInboundPlanRequest("100", 2L, 0, LocalDate.of(2026, 9, 15),
|
||||
2, "WOOD", false, false, false, null), "dropship-pallet-mismatch", "900"));
|
||||
|
||||
assertEquals(400, error.getStatusCode().value());
|
||||
assertEquals("一件代发人工计划托数必须与头程封托托盘数一致:1", error.getReason());
|
||||
verify(planMapper, never()).insert(any(InboundPlan.class));
|
||||
verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneClickDropshipUsesFrozenSourcePalletCountInsteadOfVolumeSuggestion() {
|
||||
SourceOrderSnapshot source = order(2L, "RK-2", "3.3650", "WH-A");
|
||||
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source));
|
||||
when(orderMapper.findOrderType("100", 2L)).thenReturn("ONE_CLICK_DROPSHIP");
|
||||
when(dropshipOrders.palletSnapshots("100", 2L)).thenReturn(List.of(
|
||||
new DropshipPalletSnapshot("DP-260914-0001", 10L, List.of())));
|
||||
when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(1), eq(false), eq(0), any()))
|
||||
.thenReturn(1);
|
||||
|
||||
var result = service.convertManual(new ManualInboundPlanRequest("100", 2L, 0,
|
||||
LocalDate.of(2026, 9, 15), 1, "WOOD", false, false, false, null),
|
||||
"dropship-exact-pallet-count", "900");
|
||||
|
||||
assertEquals(1, result.palletCount());
|
||||
verify(planMapper).insert(any(InboundPlan.class));
|
||||
verify(palletMapper).insert(any(InboundPlanPallet.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void containerConversionBindsRealWarehousePalletsBackToSignedManualPlanBeforeReceiving() {
|
||||
InboundPlan plan = existingPlan("100", 2L, "IP-MANUAL");
|
||||
|
||||
+22
@@ -12,6 +12,28 @@ import java.util.Map;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ReturnCartonQueryTest {
|
||||
@Test
|
||||
void baseMapperReplayColumnExistsInEveryReturnCartonBaseline() throws Exception {
|
||||
var table = com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
|
||||
new org.apache.ibatis.builder.MapperBuilderAssistant(
|
||||
new com.baomidou.mybatisplus.core.MybatisConfiguration(), "return-carton-contract"),
|
||||
com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton.class);
|
||||
assertThat(table.getAllSqlSelect()).contains("new_carton_mark");
|
||||
for (var path : java.util.List.of(java.nio.file.Path.of("src/main/resources/db/lastleg.sql"),
|
||||
java.nio.file.Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"))) {
|
||||
String sql = java.nio.file.Files.readString(path);
|
||||
var matcher = java.util.regex.Pattern.compile(
|
||||
"CREATE TABLE IF NOT EXISTS ll_return_inventory_carton \\(([\\s\\S]*?)\\) ENGINE")
|
||||
.matcher(sql);
|
||||
int definitions = 0;
|
||||
while (matcher.find()) {
|
||||
definitions++;
|
||||
assertThat(matcher.group(1)).as(path.toString()).contains("new_carton_mark VARCHAR(255)");
|
||||
}
|
||||
assertThat(definitions).isPositive();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void bothQueriesUseExactCustomerScopeAndSkuExistenceWithoutAnOuterJoin() throws Exception {
|
||||
var query = new ReturnInventoryRequests.Query(2, 1, "42", "SKU", "RC", "AVAILABLE", "RT");
|
||||
|
||||
+26
@@ -147,6 +147,32 @@ class LoadingCompletedConsumerTest {
|
||||
verify(cartons,never()).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class));
|
||||
verify(orders,never()).insert(any(LastlegOrder.class));
|
||||
}
|
||||
@Test void consumesOneDropshipSourceDetailSplitAcrossTwoPallets() {
|
||||
EventEnvelope original=overseasDropshipEvent(false);
|
||||
var node=(com.fasterxml.jackson.databind.node.ObjectNode)original.payload().deepCopy();
|
||||
var order=(com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0);
|
||||
var secondItem=(com.fasterxml.jackson.databind.node.ObjectNode)order.path("items").get(0).deepCopy();
|
||||
((com.fasterxml.jackson.databind.node.ArrayNode)order.path("items")).add(secondItem);
|
||||
order.put("packageCount",10).put("productQuantity",100).put("palletCount",2);
|
||||
var secondPallet=(com.fasterxml.jackson.databind.node.ObjectNode)order.path("pallets").get(0).deepCopy();
|
||||
secondPallet.put("palletNo","DP-260914-0022");
|
||||
var secondSku=(com.fasterxml.jackson.databind.node.ObjectNode)secondPallet.path("skuPackages").get(0);
|
||||
var marks=secondSku.putArray("cartonMarks"); for(int n=6;n<=10;n++)marks.add("RK1#10-"+n);
|
||||
((com.fasterxml.jackson.databind.node.ArrayNode)order.path("pallets")).add(secondPallet);
|
||||
|
||||
service.consume(rehash(original,node));
|
||||
|
||||
var detailCaptor=ArgumentCaptor.forClass(LastlegOrderDetail.class);
|
||||
verify(details).insert(detailCaptor.capture());
|
||||
assertEquals("d-1",detailCaptor.getValue().getSourceInboundDetailId());
|
||||
assertEquals(10,detailCaptor.getValue().getPackageCount());
|
||||
assertEquals(100,detailCaptor.getValue().getProductQuantity());
|
||||
verify(cartons,times(10)).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class));
|
||||
var received=ArgumentCaptor.forClass(DropshipRequests.Receive.class);
|
||||
verify(dropshipOrders).receive(received.capture());
|
||||
assertEquals(List.of("DP-260909-0143","DP-260914-0022"),
|
||||
received.getValue().pallets().stream().map(DropshipRequests.Pallet::palletNo).toList());
|
||||
}
|
||||
@Test void mapsSameSkuDifferentSourceDetailsWithoutGuessing() {
|
||||
EventEnvelope original=overseasDropshipEvent(false);
|
||||
var node=(com.fasterxml.jackson.databind.node.ObjectNode)original.payload().deepCopy();
|
||||
|
||||
+36
-1
@@ -222,7 +222,7 @@ class WarehouseTransferServiceTest {
|
||||
when(sourceOrders.selectList(any())).thenReturn(List.of());
|
||||
|
||||
var result = service.review("TENANT-A", "WTR-1", "USER-1",
|
||||
new WarehouseTransferRequests.Review("APPROVE", "approved"));
|
||||
new WarehouseTransferRequests.Review("APPROVE", null));
|
||||
|
||||
assertEquals(WarehouseTransferService.APPROVED, transfer.getStatus());
|
||||
assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, tailOrder.getCurrentStatus());
|
||||
@@ -233,6 +233,41 @@ class WarehouseTransferServiceTest {
|
||||
verifyNoInteractions(inventory);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectionRequiresNonBlankReasonBeforeAnyStateOrInventoryChange() {
|
||||
for (String decision : List.of("REJECT", "REJECTED")) {
|
||||
for (String reason : new String[] { null, "", " \t\n" }) {
|
||||
var error = assertThrows(ResponseStatusException.class, () -> service.review(
|
||||
"TENANT-A", "WTR-1", "USER-1", new WarehouseTransferRequests.Review(decision, reason)));
|
||||
assertEquals(400, error.getStatusCode().value());
|
||||
assertEquals("驳回原因不能为空", error.getReason());
|
||||
}
|
||||
}
|
||||
verifyNoInteractions(transfers, orders, orderHistories, inventory);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectionWithReasonCancelsLinkedOrderAndPreservesReasonInHistory() {
|
||||
WarehouseTransferOrder transfer = persistedTransfer();
|
||||
transfer.setStatus(WarehouseTransferService.PENDING_REVIEW);
|
||||
LastlegOrder tailOrder = historicalOrder();
|
||||
tailOrder.setId(31L); tailOrder.setCurrentStatus(WarehouseTransferService.PENDING_REVIEW);
|
||||
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||
when(orders.selectById(31L)).thenReturn(tailOrder);
|
||||
when(transferItems.selectList(any())).thenReturn(List.of());
|
||||
when(sourceOrders.selectList(any())).thenReturn(List.of());
|
||||
|
||||
var result = service.review("TENANT-A", "WTR-1", "USER-1",
|
||||
new WarehouseTransferRequests.Review("REJECT", " 地址不完整 "));
|
||||
assertEquals("REJECTED", result.status());
|
||||
assertEquals("CANCELLED", tailOrder.getCurrentStatus());
|
||||
var history = ArgumentCaptor.forClass(com.baiyue.lastleg.entity.LastlegOrderStatusHistory.class);
|
||||
verify(orderHistories).insert(history.capture());
|
||||
assertTrue(history.getValue().getRemark().endsWith("; 地址不完整"));
|
||||
verifyNoInteractions(inventory);
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvedTransferAllocatesExactSourceInboundPalletWithoutMovingInventory() {
|
||||
WarehouseTransferOrder transfer = transfer("APPROVED");
|
||||
|
||||
Reference in New Issue
Block a user