This commit is contained in:
2026-10-20 14:22:31 +08:00
parent 19745e46c1
commit a870b920fe
95 changed files with 2616 additions and 275 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
| warehouse-overseas-service | `baiyue_lastlag_warehouse` | 仅业务代码变化,无新增表或字段,无需 SQL;数据库名称保留正式的 lastlag 拼写 | | warehouse-overseas-service | `baiyue_lastlag_warehouse` | 仅业务代码变化,无新增表或字段,无需 SQL;数据库名称保留正式的 lastlag 拼写 |
| 其他服务 | 各服务原数据库 | 本次比较范围内无 SQL 结构新增,无需 SQL | | 其他服务 | 各服务原数据库 | 本次比较范围内无 SQL 结构新增,无需 SQL |
已有尾程库使用:[V28 可重复执行 SQL](migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql)。文件显式指定 `USE baiyue_lastleg`,不访问其他服务数据库。 已有尾程库使用:[V28 可重复执行 SQL](migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql)。由单 schema 执行器选定 `baiyue_lastleg`,文件内不切库,不访问其他服务数据库。2026-10-20 按尚未执行候选的边界移除内部切库语句,其余结构内容不变;本次未执行此候选。
## 新表与字段 ## 新表与字段
@@ -1,5 +1,5 @@
-- 仅为已初始化的头程库新增独立确认历史;不回填费用确认,不修改订单事实。 -- 仅为已初始化的头程库新增独立确认历史;不回填费用确认,不修改订单事实。
USE baiyue_firstleg; -- 由单 schema 执行器选择 baiyue_firstleg,不在迁移内部切库。
-- 客户独立确认计费重,不回填既有费用确认。 -- 客户独立确认计费重,不回填既有费用确认。
CREATE TABLE IF NOT EXISTS fl_chargeable_weight_confirmation ( CREATE TABLE IF NOT EXISTS fl_chargeable_weight_confirmation (
id BIGINT NOT NULL COMMENT '确认记录ID', id BIGINT NOT NULL COMMENT '确认记录ID',
@@ -2,7 +2,7 @@
-- 目标:lastleg-service / baiyue_lastleg;前置:已有业务表及此前迁移。 -- 目标:lastleg-service / baiyue_lastleg;前置:已有业务表及此前迁移。
-- 只调整结构,不回填、删除或重算业务数据;执行失败应停止,修复后可重跑。 -- 只调整结构,不回填、删除或重算业务数据;执行失败应停止,修复后可重跑。
SET NAMES utf8mb4; SET NAMES utf8mb4;
USE baiyue_lastleg; -- 由单 schema 执行器选择 baiyue_lastleg,不在迁移内部切库。
CREATE TABLE IF NOT EXISTS ll_compact_number_clock ( CREATE TABLE IF NOT EXISTS ll_compact_number_clock (
number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写', number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写',
@@ -11,7 +11,6 @@ CREATE TABLE IF NOT EXISTS ll_compact_number_clock (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟';
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。 -- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米''')); SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='width_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN width_cm DECIMAL(12,2) NULL COMMENT ''箱子实测宽度厘米''')); SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='width_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN width_cm DECIMAL(12,2) NULL COMMENT ''箱子实测宽度厘米'''));
@@ -42,7 +41,6 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE transfer_quantity_stmt FROM @ddl; EXECUTE transfer_quantity_stmt; DEALLOCATE PREPARE transfer_quantity_stmt; PREPARE transfer_quantity_stmt FROM @ddl; EXECUTE transfer_quantity_stmt; DEALLOCATE PREPARE transfer_quantity_stmt;
-- 仓储转运一箱多SKU清点;仅增加结构,不回填历史业务数据。 -- 仓储转运一箱多SKU清点;仅增加结构,不回填历史业务数据。
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='sku_items_json'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN sku_items_json JSON NULL COMMENT ''实收箱内SKU及逐项件数明细''')); SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='sku_items_json'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN sku_items_json JSON NULL COMMENT ''实收箱内SKU及逐项件数明细'''));
PREPARE transfer_sku_stmt FROM @ddl; EXECUTE transfer_sku_stmt; DEALLOCATE PREPARE transfer_sku_stmt; PREPARE transfer_sku_stmt FROM @ddl; EXECUTE transfer_sku_stmt; DEALLOCATE PREPARE transfer_sku_stmt;
@@ -0,0 +1,49 @@
-- Region: OVERSEAS
-- Schema: baiyue_lastleg
-- Purpose: 排车物理托盘串行点、物理占用及逻辑占用关联结构补齐。
-- Preconditions: MySQL 8;已选中目标schema;ll_dispatch_pallet_lock已有业务基线存在。
-- Postconditions: 两张实托表和physical_lock_id列/idx_dispatch_logical_physical索引存在。
-- 只增加缺失结构,不删除/回填/重算历史数据。重复执行已存在对象时跳过。
-- 与当前lastleg.sql最终定义一致;已有异构对象须人工核对,不能以跳过证明一致。
CREATE TABLE IF NOT EXISTS ll_physical_pallet_guard (
warehouse_pallet_id BIGINT NOT NULL COMMENT '真实全局物理托盘ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次建立串行点时间',
PRIMARY KEY (warehouse_pallet_id)
) ENGINE=InnoDB COMMENT='排车冻结与仓内实托组成修改的共同串行点';
CREATE TABLE IF NOT EXISTS ll_dispatch_physical_pallet_lock (
id BIGINT NOT NULL COMMENT '物理实托占用主键',
warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运仓ID',
warehouse_pallet_id BIGINT NOT NULL COMMENT '全局真实物理托盘ID',
active_warehouse_pallet_id BIGINT NULL COMMENT '活动物理排他键,释放后为空',
dispatch_order_id BIGINT NULL COMMENT '正式排车主单ID,与草稿归属互斥',
draft_token VARCHAR(64) NULL COMMENT '草稿拥有者令牌,与正式排车归属互斥',
draft_container_id BIGINT NULL COMMENT '草稿内唯一占用该实托的容器ID',
operator_id VARCHAR(128) NULL COMMENT '占用操作人ID',
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '物理占用状态',
expires_at DATETIME(3) NULL COMMENT '草稿失效时间,正式占用为空',
locked_at DATETIME(3) NOT NULL COMMENT '锁定时间',
released_at DATETIME(3) NULL COMMENT '释放时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_dispatch_physical_active_pallet (active_warehouse_pallet_id),
KEY idx_dispatch_physical_order (dispatch_order_id,status),
KEY idx_dispatch_physical_draft (draft_token,draft_container_id,status),
CONSTRAINT chk_dispatch_physical_active_key CHECK((status='ACTIVE' AND active_warehouse_pallet_id IS NOT NULL AND active_warehouse_pallet_id=warehouse_pallet_id) OR (status<>'ACTIVE' AND active_warehouse_pallet_id IS NULL)),
CONSTRAINT chk_dispatch_physical_owner CHECK((dispatch_order_id IS NOT NULL AND draft_token IS NULL AND draft_container_id IS NULL) OR (dispatch_order_id IS NULL AND draft_token IS NOT NULL AND draft_container_id IS NOT NULL))
) ENGINE=InnoDB COMMENT='同车共享且跨车排他的物理实托占用';
-- 已只读确认本地缺失:客户逻辑锁到物理锁的可空关联列及查询索引。
-- 仅补结构,不根据历史单据推测或回填关联。
SET @overseas_dimension_ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='physical_lock_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN physical_lock_id BIGINT NULL COMMENT ''实际物理实托占用ID,非物理来源为空''');
PREPARE overseas_dimension_stmt FROM @overseas_dimension_ddl;
EXECUTE overseas_dimension_stmt;
DEALLOCATE PREPARE overseas_dimension_stmt;
SET @overseas_dimension_ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND index_name='idx_dispatch_logical_physical'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD KEY idx_dispatch_logical_physical (physical_lock_id,status)');
PREPARE overseas_dimension_stmt FROM @overseas_dimension_ddl;
EXECUTE overseas_dimension_stmt;
DEALLOCATE PREPARE overseas_dimension_stmt;
SET @overseas_dimension_ddl = NULL;
@@ -91,6 +91,15 @@ public class LastlegOrderController {
return ApiResponse.success(orderService.page(platformScope(user, customerId), query, relabelNo)); return ApiResponse.success(orderService.page(platformScope(user, customerId), query, relabelNo));
} }
/** 按主列表完整筛选读取仓储转运数量,保持同一平台客户边界和列表权限。 */
@GetMapping("/api/platform/v1/last-leg/orders/warehouse-transfer-quantities")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
public ApiResponse<com.baiyue.lastleg.transfer.dto.TransferQuantityTotals> transferQuantities(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required=false) String customerId,
@RequestParam(required=false) String relabelNo, @Valid LastlegOrderRequests.Query query) {
return ApiResponse.success(orderService.transferQuantityTotals(platformScope(user,customerId),query,relabelNo));
}
/** Compatibility overload used by non-Spring callers and existing contract tests. */ /** Compatibility overload used by non-Spring callers and existing contract tests. */
public ApiResponse<PageResult<LastlegOrderSummary>> list( public ApiResponse<PageResult<LastlegOrderSummary>> list(
AuthenticatedUser user, String customerId, LastlegOrderRequests.Query query) { AuthenticatedUser user, String customerId, LastlegOrderRequests.Query query) {
@@ -597,12 +597,19 @@ public class DeliveryTransferService {
if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) { if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
throw conflict("换标单尚未完成客户确认或已不可出库"); throw conflict("换标单尚未完成客户确认或已不可出库");
} }
if (relabelSnapshotMarks(item, selectedPosition.getId(), application.getRelabelNo()).isEmpty())
throw conflict("换标出库指定位置或箱唛快照不完整,请核对原申请");
List<RelabelItem> sourceItems = relabelItems.selectList(new LambdaQueryWrapper<RelabelItem>() List<RelabelItem> sourceItems = relabelItems.selectList(new LambdaQueryWrapper<RelabelItem>()
.eq(RelabelItem::getApplicationId, application.getId()) .eq(RelabelItem::getApplicationId, application.getId())
.eq(RelabelItem::getCustomerId, customerId) .eq(RelabelItem::getCustomerId, customerId)
.eq(RelabelItem::getSourceInventoryPositionId, request.inventoryPositionId())); .eq(RelabelItem::getSourceInventoryPositionId, request.inventoryPositionId()));
long sourcePackages = sourceItems.stream().mapToLong(source -> value(source.getNotifiedPackageCount())).sum(); long sourcePackages = sourceItems.stream().mapToLong(source -> value(source.getNotifiedPackageCount())).sum();
if (sourcePackages != request.packages()) throw conflict("换标出库来源库存快照与任务箱数不一致"); List<String> sourceMarks = sourceItems.stream().map(RelabelItem::getCartonMark)
.filter(StringUtils::hasText).distinct().sorted().toList();
Set<String> sourceIds = sourceItems.stream().map(source -> String.valueOf(source.getId())).collect(Collectors.toSet());
if (sourcePackages != request.packages() || !sourceMarks.equals(expectedMarks)
|| !sourceIds.equals(relabelSnapshotItemIds(item)))
throw conflict("换标出库来源库存快照与任务箱数或箱唛不一致");
int releaseIndex = 0; int releaseIndex = 0;
for (RelabelItem source : sourceItems) { for (RelabelItem source : sourceItems) {
inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move( inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move(
@@ -720,6 +727,7 @@ public class DeliveryTransferService {
return taskResult(outbound, task); return taskResult(outbound, task);
} }
/** 扫描整托前核平全部指定来源,任一不足时不执行部分拣货。 */
@Transactional @Transactional
public DeliveryResponses.PdaTask pdaPickPallet(String warehouseId, String operatorId, String taskNo, public DeliveryResponses.PdaTask pdaPickPallet(String warehouseId, String operatorId, String taskNo,
String palletNo, DeliveryRequests.PdaPalletPick request) { String palletNo, DeliveryRequests.PdaPalletPick request) {
@@ -769,7 +777,7 @@ public class DeliveryTransferService {
long remaining = value(item.getPackageCount()) - value(item.getPickedPackageCount()); long remaining = value(item.getPackageCount()) - value(item.getPickedPackageCount());
if (remaining <= 0) continue; if (remaining <= 0) continue;
Map<Long, List<DeliveryResponses.PdaPickPosition>> positionsById = pdaPickPositions( Map<Long, List<DeliveryResponses.PdaPickPosition>> positionsById = pdaPickPositions(
task.getCustomerId(), boundWarehouseId, item).stream().collect(Collectors.groupingBy( task.getCustomerId(), boundWarehouseId, item, outbound.getOutboundType()).stream().collect(Collectors.groupingBy(
DeliveryResponses.PdaPickPosition::inventoryPositionId, LinkedHashMap::new, Collectors.toList())); DeliveryResponses.PdaPickPosition::inventoryPositionId, LinkedHashMap::new, Collectors.toList()));
for (Map.Entry<Long, List<DeliveryResponses.PdaPickPosition>> entry : positionsById.entrySet()) { for (Map.Entry<Long, List<DeliveryResponses.PdaPickPosition>> entry : positionsById.entrySet()) {
if (remaining <= 0) break; if (remaining <= 0) break;
@@ -1049,6 +1057,7 @@ public class DeliveryTransferService {
.map(task -> taskResult(requireOutboundInWarehouse(task, boundWarehouseId), task)).toList(); .map(task -> taskResult(requireOutboundInWarehouse(task, boundWarehouseId), task)).toList();
} }
/** PDA明细展示与整托拣货使用相同的客户、仓库及来源投影。 */
public DeliveryResponses.PdaTaskDetail pdaTaskDetail(String warehouseId, String taskNo) { public DeliveryResponses.PdaTaskDetail pdaTaskDetail(String warehouseId, String taskNo) {
String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required"); String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required");
OutboundPdaTask task = pdaTasks.findByNoAndWarehouse(required(taskNo, "PDA task number is required"), OutboundPdaTask task = pdaTasks.findByNoAndWarehouse(required(taskNo, "PDA task number is required"),
@@ -1061,7 +1070,7 @@ public class DeliveryTransferService {
value(item.getPickedPackageCount()), item.getStatus(), value(item.getPickedPackageCount()), item.getStatus(),
("RETURN_OUTBOUND".equals(outbound.getOutboundType()) ("RETURN_OUTBOUND".equals(outbound.getOutboundType())
|| ("RELABEL_OUTBOUND".equals(outbound.getOutboundType()) && item.getWarehousePalletId() == null)) ? returnPickPositions(outbound, task, item) || ("RELABEL_OUTBOUND".equals(outbound.getOutboundType()) && item.getWarehousePalletId() == null)) ? returnPickPositions(outbound, task, item)
: pdaPickPositions(task.getCustomerId(), boundWarehouseId, item), item.getReturnSkuQuantity())).toList(); : pdaPickPositions(task.getCustomerId(), boundWarehouseId, item, outbound.getOutboundType()), item.getReturnSkuQuantity())).toList();
return new DeliveryResponses.PdaTaskDetail(taskResult(outbound, task), items); return new DeliveryResponses.PdaTaskDetail(taskResult(outbound, task), items);
} }
@@ -1119,13 +1128,28 @@ public class DeliveryTransferService {
&& value(item.getPickedPackageCount()) == 0 && "OUTBOUND_PENDING".equals(carton.getStatus()) ? 1 : 0, value(item.getPickedPackageCount()))); && value(item.getPickedPackageCount()) == 0 && "OUTBOUND_PENDING".equals(carton.getStatus()) ? 1 : 0, value(item.getPickedPackageCount())));
} }
/** 换标使用指定实物箱及仍归本单的冻结库存;普通出库继续按可用库存和SKU匹配。 */
private List<DeliveryResponses.PdaPickPosition> pdaPickPositions(String customerId, String warehouseId, private List<DeliveryResponses.PdaPickPosition> pdaPickPositions(String customerId, String warehouseId,
OutboundPalletItem item) { OutboundPalletItem item, String outboundType) {
if (item.getWarehousePalletId() == null) return List.of(); if (item.getWarehousePalletId() == null) return List.of();
List<DeliveryResponses.PdaPickPosition> result = new ArrayList<>(); List<DeliveryResponses.PdaPickPosition> result = new ArrayList<>();
Set<String> frozenMarks = new HashSet<>(Arrays.asList( Set<String> frozenMarks = new HashSet<>(Arrays.asList(
Optional.ofNullable(item.getCartonMarks()).orElse("").split(","))); Optional.ofNullable(item.getCartonMarks()).orElse("").split(",")));
for (InventoryPosition position : inventoryPositions.listPallet(customerId, item.getWarehousePalletId())) { for (InventoryPosition position : inventoryPositions.listPallet(customerId, item.getWarehousePalletId())) {
if ("RELABEL_OUTBOUND".equals(outboundType)) {
List<String> marks = relabelSnapshotMarks(item, position.getId(), null);
InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId());
if (marks.isEmpty() || !customerId.equals(position.getCustomerId())
|| !warehouseId.equals(position.getOverseasWarehouseId())
|| !Objects.equals(item.getWarehousePalletId(), position.getPalletId())
|| balance == null || !customerId.equals(balance.getCustomerId())
|| !warehouseId.equals(balance.getOverseasWarehouseId())
|| !Objects.equals(item.getInboundCode(), balance.getInboundCode())
|| value(position.getHoldPackages()) < marks.size()) continue;
// 本单冻结箱先在写路径校验原申请和来源任务,再释放、分配、拣货、出库;不把其他可用箱混入。
for (String mark : marks) result.add(pdaPosition(position, item, balance, mark, 1, 0));
continue;
}
long available = value(position.getAvailablePackages()); long available = value(position.getAvailablePackages());
if (available <= 0 || !warehouseId.equals(position.getOverseasWarehouseId())) continue; if (available <= 0 || !warehouseId.equals(position.getOverseasWarehouseId())) continue;
InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId()); InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId());
@@ -1147,6 +1171,37 @@ public class DeliveryTransferService {
return result; return result;
} }
/** 仅接受完整的换标出库快照;空SKU不是通配符,必须精确匹配返工后冻结的位置和全部箱唛。 */
private List<String> relabelSnapshotMarks(OutboundPalletItem item, Long positionId, String expectedRelabelNo) {
try {
var refs = objectMapper.readTree(item.getOrderRefs());
if (!"RELABEL_ORDER".equals(refs.path("sourceType").asText())
|| !StringUtils.hasText(refs.path("relabelNo").asText())
|| expectedRelabelNo != null && !expectedRelabelNo.equals(refs.path("relabelNo").asText())
|| positionId == null || !String.valueOf(positionId).equals(refs.path("sourceInventoryPositionId").asText())
|| !refs.path("items").isArray()) return List.of();
var marks = new TreeSet<String>(); var ids = new HashSet<String>(); long packages = 0;
for (var line : refs.path("items")) {
String mark = line.path("cartonMark").asText().trim(), id = line.path("itemId").asText();
long count = line.path("packages").asLong();
if (!StringUtils.hasText(mark) || !StringUtils.hasText(id) || !ids.add(id) || !marks.add(mark) || count != 1) return List.of();
packages += count;
}
var frozen = Arrays.stream(Objects.toString(item.getCartonMarks(), "").split(","))
.map(String::trim).filter(StringUtils::hasText).collect(Collectors.toCollection(TreeSet::new));
return packages == value(item.getPackageCount()) && marks.equals(frozen) ? List.copyOf(marks) : List.of();
} catch (Exception ignored) { return List.of(); }
}
/** 释放冻结前核对快照任务主键与本申请当前位置的真实任务集合完全相同。 */
private Set<String> relabelSnapshotItemIds(OutboundPalletItem item) {
try {
var ids = new HashSet<String>();
for (var line : objectMapper.readTree(item.getOrderRefs()).path("items")) ids.add(line.path("itemId").asText());
return ids;
} catch (Exception ignored) { return Set.of(); }
}
private DeliveryResponses.PdaPickPosition pdaPosition(InventoryPosition position, OutboundPalletItem item, private DeliveryResponses.PdaPickPosition pdaPosition(InventoryPosition position, OutboundPalletItem item,
InventoryBalance balance, String cartonMark, InventoryBalance balance, String cartonMark,
long available, long picked) { long available, long picked) {
@@ -29,12 +29,16 @@ public final class CargoDiscardResponses {
} }
} }
public record Box(Long cartonId, String cartonMark, String palletNo, List<CandidateSku> skus) {} public record Box(Long cartonId, String cartonMark, String palletNo, List<CandidateSku> skus) {}
public record CandidateSku(String skuCode, String productName, long availablePackages) {} public record CandidateSku(String skuCode, String productName, long availablePackages, Long availablePieces) {
/** 无件数事实时保留未知,不按箱规推算。 */
public CandidateSku(String skuCode, String productName, long availablePackages) { this(skuCode, productName, availablePackages, null); }
}
public record Summary(Long id, String customerId, String discardNo, String inboundCodes, public record Summary(Long id, String customerId, String discardNo, String inboundCodes,
String overseasWarehouseId, String overseasWarehouseName, String overseasWarehouseId, String overseasWarehouseName,
int totalPackages, BigDecimal totalFee, String currency, String status, int totalPackages, BigDecimal totalFee, String currency, String status,
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt) {} String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt,
List<String> skuCodes, int skuCount, int requestedPackages, Integer actualPackages) {}
public record Item(Long id, Long orderId, String inboundCode, String skuCode, public record Item(Long id, Long orderId, String inboundCode, String skuCode,
String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks, String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks,
@@ -19,4 +19,15 @@ public interface CargoDiscardDimensionMapper {
WHERE i.customer_id=#{customerId} AND i.application_id=#{applicationId} WHERE i.customer_id=#{customerId} AND i.application_id=#{applicationId}
""") """)
List<String> palletNos(@Param("customerId") String customerId, @Param("applicationId") Long applicationId); List<String> palletNos(@Param("customerId") String customerId, @Param("applicationId") Long applicationId);
/** 仅读取所选转运实收箱的真实件数,其他来源保留未知。 */
@Select("""
SELECT CASE WHEN l.source_type='TRANSFER' THEN c.sku_items_json ELSE NULL END
FROM ll_cargo_discard_item i
JOIN ll_cargo_discard_carton l ON l.customer_id=i.customer_id AND l.application_id=i.application_id
AND (l.item_id=i.id OR (i.source_type='INVENTORY' AND JSON_CONTAINS(i.carton_marks_json,JSON_QUOTE(l.carton_mark))))
LEFT JOIN ll_warehouse_transfer_carton c ON c.id=l.carton_id AND c.customer_id=l.customer_id
WHERE i.customer_id=#{customerId} AND i.id=#{itemId}
""")
List<String> selectedCartonPieces(@Param("customerId") String customerId, @Param("itemId") Long itemId);
} }
@@ -25,16 +25,16 @@ public interface CargoDiscardInventoryMapper {
@Select("SELECT p.* FROM ll_inventory_position p WHERE p.customer_id=#{customerId} AND p.id=#{positionId} FOR UPDATE") @Select("SELECT p.* FROM ll_inventory_position p WHERE p.customer_id=#{customerId} AND p.id=#{positionId} FOR UPDATE")
com.baiyue.lastleg.inventory.entity.InventoryPosition lockGenericPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId); com.baiyue.lastleg.inventory.entity.InventoryPosition lockGenericPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId);
/** 按实收托盘及 SKU 关联已清点箱,避免漏箱或混入其他托盘的箱号。 */ /** 按实收托盘关联已清点箱;混装物理余额允许箱内不同 SKU,仍严格限定客户、批次、仓和托盘。 */
@Select(""" @Select("""
SELECT c.id,c.carton_mark,c.sku_code,'INBOUND' source_type SELECT c.id,c.carton_mark,c.sku_code,'INBOUND' source_type,NULL sku_items_json
FROM ll_inbound_plan_carton c FROM ll_inbound_plan_carton c
JOIN ll_inventory_balance b ON b.customer_id=c.customer_id AND b.inbound_order_id=c.order_id JOIN ll_inventory_balance b ON b.customer_id=c.customer_id AND b.inbound_order_id=c.order_id
JOIN ll_inventory_position p ON p.customer_id=b.customer_id AND p.balance_id=b.id AND p.pallet_id=c.warehouse_pallet_id JOIN ll_inventory_position p ON p.customer_id=b.customer_id AND p.balance_id=b.id AND p.pallet_id=c.warehouse_pallet_id
WHERE p.customer_id=#{customerId} AND p.id=#{positionId} AND c.status='AVAILABLE' AND c.received_at IS NOT NULL WHERE p.customer_id=#{customerId} AND p.id=#{positionId} AND c.status='AVAILABLE' AND c.received_at IS NOT NULL
AND COALESCE(b.destination_warehouse_code,'')=COALESCE(c.destination_warehouse_code,'') AND COALESCE(b.destination_warehouse_code,'')=COALESCE(c.destination_warehouse_code,'')
UNION ALL UNION ALL
SELECT c.id,c.carton_mark,i.sku_code,'TRANSFER' source_type SELECT c.id,c.carton_mark,i.sku_code,'TRANSFER' source_type,c.sku_items_json
FROM ll_inventory_position p FROM ll_inventory_position p
JOIN ll_inventory_balance b ON b.customer_id=p.customer_id AND b.id=p.balance_id JOIN ll_inventory_balance b ON b.customer_id=p.customer_id AND b.id=p.balance_id
JOIN ll_warehouse_transfer_order t ON t.customer_id=p.customer_id AND t.inbound_order_id=b.inbound_order_id JOIN ll_warehouse_transfer_order t ON t.customer_id=p.customer_id AND t.inbound_order_id=b.inbound_order_id
@@ -44,7 +44,7 @@ public interface CargoDiscardInventoryMapper {
JOIN ll_warehouse_transfer_carton c ON c.customer_id=t.customer_id AND c.transfer_order_id=t.id JOIN ll_warehouse_transfer_carton c ON c.customer_id=t.customer_id AND c.transfer_order_id=t.id
AND c.transfer_pallet_id=tp.id AND c.status='COUNTED' AND c.transfer_pallet_id=tp.id AND c.status='COUNTED'
JOIN ll_warehouse_transfer_item i ON i.customer_id=c.customer_id AND i.id=c.transfer_item_id JOIN ll_warehouse_transfer_item i ON i.customer_id=c.customer_id AND i.id=c.transfer_item_id
AND i.transfer_order_id=t.id AND COALESCE(i.sku_code,'')=COALESCE(b.sku_code,'') AND i.transfer_order_id=t.id AND (b.sku_code='MIXED' OR COALESCE(i.sku_code,'')=COALESCE(b.sku_code,''))
WHERE p.customer_id=#{customerId} AND p.id=#{positionId} WHERE p.customer_id=#{customerId} AND p.id=#{positionId}
ORDER BY id ORDER BY id
""") """)
@@ -12,15 +12,22 @@ public final class CargoDiscardQuantities {
/** 退货混装箱按箱 ID 去重,其余来源累加已选择的物理箱数。 */ /** 退货混装箱按箱 ID 去重,其余来源累加已选择的物理箱数。 */
public static int boxes(List<CargoDiscardItem> lines, boolean actual) { public static int boxes(List<CargoDiscardItem> lines, boolean actual) {
var returnBoxes = new HashSet<Long>(); var returnBoxes = new HashSet<Long>();
var inventoryBoxes = new HashSet<String>();
int total = 0; int total = 0;
for (var line : lines) { for (var line : lines) {
int quantity = actual ? java.util.Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) : line.getDiscardPackages(); int quantity = actual ? java.util.Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) : line.getDiscardPackages();
if (line.getReturnCartonId() != null) { if (line.getReturnCartonId() != null) {
if (quantity > 0) returnBoxes.add(line.getReturnCartonId()); if (quantity > 0) returnBoxes.add(line.getReturnCartonId());
} else if ("INVENTORY".equals(line.getSourceType()) && line.getCartonMarksJson() != null && quantity > 0) {
try {
var marks = new com.fasterxml.jackson.databind.ObjectMapper().readTree(line.getCartonMarksJson());
if (!marks.isArray() || marks.size() != quantity) throw new IllegalStateException("废弃箱唛快照与数量不一致");
for (var mark : marks) inventoryBoxes.add(line.getInboundCode() + ":" + mark.asText());
} catch (com.fasterxml.jackson.core.JsonProcessingException e) { throw new IllegalStateException("废弃箱唛快照无法读取", e); }
} else { } else {
total = Math.addExact(total, quantity); total = Math.addExact(total, quantity);
} }
} }
return Math.addExact(total, returnBoxes.size()); return Math.addExact(Math.addExact(total, returnBoxes.size()), inventoryBoxes.size());
} }
} }
@@ -9,4 +9,5 @@ public class CargoDiscardStockCartonRow {
private String cartonMark; private String cartonMark;
private String skuCode; private String skuCode;
private String sourceType; private String sourceType;
private String skuItemsJson;
} }
@@ -21,7 +21,8 @@ public final class PdaCargoDiscardDtos {
} }
/** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */ /** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */
public record Sku(Long itemId, String skuCode, String productName, int plannedPackages, public record Sku(Long itemId, String skuCode, String productName, int plannedPackages,
Integer actualPackages, String status, String quantityUnit) { } Integer actualPackages, String status, String quantityUnit,
boolean actualQuantityEditable) { }
public record PalletTask(String discardNo, String customerId, String overseasWarehouseId, public record PalletTask(String discardNo, String customerId, String overseasWarehouseId,
String palletNo, Long profileId, String status, List<Sku> skus, String outboundNo, String palletNo, Long profileId, String status, List<Sku> skus, String outboundNo,
String scanMode, List<String> cartonMarks) { } String scanMode, List<String> cartonMarks) { }
@@ -1,17 +1,11 @@
package com.baiyue.lastleg.discard.pda.service; package com.baiyue.lastleg.discard.pda.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.lastleg.discard.dto.CargoDiscardResponses; import com.baiyue.lastleg.discard.dto.CargoDiscardResponses;
import com.baiyue.lastleg.discard.entity.*; import com.baiyue.lastleg.discard.entity.*;
import com.baiyue.lastleg.discard.mapper.*;
import com.baiyue.lastleg.discard.pda.dto.PdaCargoDiscardDtos; import com.baiyue.lastleg.discard.pda.dto.PdaCargoDiscardDtos;
import com.baiyue.lastleg.discard.service.CargoDiscardInventoryAdjustmentService; import com.baiyue.lastleg.discard.service.CargoDiscardInventoryAdjustmentService;
import com.baiyue.lastleg.discard.service.CargoDiscardService; import com.baiyue.lastleg.discard.service.CargoDiscardService;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -24,28 +18,24 @@ import java.util.stream.Collectors;
@Service @Service
public class PdaCargoDiscardService { public class PdaCargoDiscardService {
@Resource private com.baiyue.lastleg.discard.service.CargoDiscardPhotoService photoService; @Resource private com.baiyue.lastleg.discard.service.CargoDiscardPhotoService photoService;
@Resource private CargoDiscardApplicationMapper applications; @Resource private com.baiyue.lastleg.discard.repository.CargoDiscardPdaRepository repository;
@Resource private com.baiyue.lastleg.discard.service.CargoDiscardOutboundService outboundService; @Resource private com.baiyue.lastleg.discard.service.CargoDiscardOutboundService outboundService;
@Resource private CargoDiscardItemMapper items;
@Resource private CargoDiscardCartonMapper cartons;
@Resource private DropshipOrderProfileMapper profiles;
@Resource private CargoDiscardInventoryAdjustmentService adjustments; @Resource private CargoDiscardInventoryAdjustmentService adjustments;
@Resource private CargoDiscardService discardService; @Resource private CargoDiscardService discardService;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private com.baiyue.lastleg.discard.service.CargoDiscardStockService stockService; @Resource private com.baiyue.lastleg.discard.service.CargoDiscardStockService stockService;
/** 只读取绑定仓已审核申请的实际托盘任务。 */
public List<PdaCargoDiscardDtos.PalletTask> tasks(PdaWarehouseIdentity identity) { public List<PdaCargoDiscardDtos.PalletTask> tasks(PdaWarehouseIdentity identity) {
return applications.selectList(new LambdaQueryWrapper<CargoDiscardApplication>() return repository.approved(String.valueOf(identity.overseasWarehouseId()))
.eq(CargoDiscardApplication::getOverseasWarehouseId, String.valueOf(identity.overseasWarehouseId()))
.eq(CargoDiscardApplication::getStatus, CargoDiscardService.APPROVED)
.orderByAsc(CargoDiscardApplication::getCreatedAt))
.stream().flatMap(a -> palletTasks(a).stream()).toList(); .stream().flatMap(a -> palletTasks(a).stream()).toList();
} }
/** 扫码读取当前仓库唯一可操作任务。 */
public PdaCargoDiscardDtos.PalletTask resolve(PdaWarehouseIdentity identity, String palletNo) { public PdaCargoDiscardDtos.PalletTask resolve(PdaWarehouseIdentity identity, String palletNo) {
return resolve(identity, palletNo, null); return resolve(identity, palletNo, null);
} }
/** 扫码时限定已选择申请,防止同托盘多任务误操作。 */
public PdaCargoDiscardDtos.PalletTask resolve(PdaWarehouseIdentity identity, String palletNo, String discardNo) { public PdaCargoDiscardDtos.PalletTask resolve(PdaWarehouseIdentity identity, String palletNo, String discardNo) {
var matches = tasks(identity).stream().filter(t -> t.palletNo().equals(palletNo.trim()) || t.cartonMarks().contains(palletNo.trim())) var matches = tasks(identity).stream().filter(t -> t.palletNo().equals(palletNo.trim()) || t.cartonMarks().contains(palletNo.trim()))
.filter(t -> discardNo == null || discardNo.isBlank() || t.discardNo().equals(discardNo.trim())).toList(); .filter(t -> discardNo == null || discardNo.isBlank() || t.discardNo().equals(discardNo.trim())).toList();
@@ -54,10 +44,11 @@ public class PdaCargoDiscardService {
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "该仓库没有此托盘的待废弃任务")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "该仓库没有此托盘的待废弃任务"));
} }
/** 仓库按无箱唛对应任务的实点数结算,其余任务继续核平所选箱。 */
@Transactional @Transactional
public PdaCargoDiscardDtos.PalletTask confirm(PdaWarehouseIdentity identity, String discardNo, public PdaCargoDiscardDtos.PalletTask confirm(PdaWarehouseIdentity identity, String discardNo,
String palletNo, PdaCargoDiscardDtos.Confirm request) { String palletNo, PdaCargoDiscardDtos.Confirm request) {
CargoDiscardApplication application = applications.findNoByWarehouse(discardNo, CargoDiscardApplication application = repository.lockApplication(discardNo,
String.valueOf(identity.overseasWarehouseId())); String.valueOf(identity.overseasWarehouseId()));
if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃任务不存在或不属于当前仓库"); if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃任务不存在或不属于当前仓库");
boolean terminal = CargoDiscardService.PDA_COMPLETED.equals(application.getStatus()) boolean terminal = CargoDiscardService.PDA_COMPLETED.equals(application.getStatus())
@@ -71,8 +62,12 @@ public class PdaCargoDiscardService {
if (!actuals.keySet().equals(task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).collect(Collectors.toSet()))) { if (!actuals.keySet().equals(task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).collect(Collectors.toSet()))) {
throw conflict("必须提交该托盘全部 SKU 的实际废弃数量"); throw conflict("必须提交该托盘全部 SKU 的实际废弃数量");
} }
if (task.skus().stream().anyMatch(line -> !Objects.equals(actuals.get(line.itemId()), line.plannedPackages()))) for (var line : task.skus()) {
throw conflict("必须完成本次废弃单所选全部箱子,不能漏出"); int actual = actuals.get(line.itemId());
if (actual < 0 || actual > line.plannedPackages()) throw conflict("实际废弃数量不能超过申请预留数量");
if (!line.actualQuantityEditable() && actual != line.plannedPackages())
throw conflict("必须完成本次废弃单所选全部箱子,不能漏出");
}
if ("CARTON".equals(task.scanMode()) && !Boolean.TRUE.equals(request.confirmAllCartons())) { if ("CARTON".equals(task.scanMode()) && !Boolean.TRUE.equals(request.confirmAllCartons())) {
var scanned = request.scannedCartonMarks() == null ? List.<String>of() : request.scannedCartonMarks(); var scanned = request.scannedCartonMarks() == null ? List.<String>of() : request.scannedCartonMarks();
if (scanned.size() != task.cartonMarks().size() || !new HashSet<>(scanned).equals(new HashSet<>(task.cartonMarks()))) if (scanned.size() != task.cartonMarks().size() || !new HashSet<>(scanned).equals(new HashSet<>(task.cartonMarks())))
@@ -80,7 +75,7 @@ public class PdaCargoDiscardService {
} }
List<CargoDiscardItem> palletItems = new ArrayList<>(); List<CargoDiscardItem> palletItems = new ArrayList<>();
for (PdaCargoDiscardDtos.Sku line : task.skus()) { for (PdaCargoDiscardDtos.Sku line : task.skus()) {
CargoDiscardItem item = items.selectById(line.itemId()); CargoDiscardItem item = repository.item(line.itemId());
if (item == null || !application.getCustomerId().equals(item.getCustomerId()) if (item == null || !application.getCustomerId().equals(item.getCustomerId())
|| !Objects.equals(application.getId(), item.getApplicationId()) || !Objects.equals(application.getId(), item.getApplicationId())
|| !physicalCode(application, item).equals(task.palletNo())) || !physicalCode(application, item).equals(task.palletNo()))
@@ -90,14 +85,14 @@ public class PdaCargoDiscardService {
throw conflict("已完成托盘的实际数量不能修改"); throw conflict("已完成托盘的实际数量不能修改");
palletItems.add(item); palletItems.add(item);
} }
if (terminal && items.listApplication(application.getCustomerId(), application.getId()).stream() if (terminal && repository.items(application.getCustomerId(), application.getId()).stream()
.anyMatch(item -> !"COMPLETED".equals(item.getPdaStatus()) .anyMatch(item -> !"COMPLETED".equals(item.getPdaStatus())
|| !application.getCustomerId().equals(item.getCustomerId()) || !application.getCustomerId().equals(item.getCustomerId())
|| !Objects.equals(application.getId(), item.getApplicationId()))) || !Objects.equals(application.getId(), item.getApplicationId())))
throw conflict("废弃申请终态存在未完成明细,不能恢复作业结果"); throw conflict("废弃申请终态存在未完成明细,不能恢复作业结果");
photoService.confirm(application, task.palletNo(), request.photoFileIds(), photoService.confirm(application, task.palletNo(), request.photoFileIds(),
palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))); palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus())));
// A completed pallet is an immutable result, including the final pallet after application completion. // 已完成托盘保持原始实点结果,终态重放不得再次扣减或释放库存。
if (palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) return task; if (palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) return task;
if (terminal) throw conflict("废弃终态不能执行未完成托盘"); if (terminal) throw conflict("废弃终态不能执行未完成托盘");
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
@@ -105,20 +100,25 @@ public class PdaCargoDiscardService {
if ("COMPLETED".equals(item.getPdaStatus())) { if ("COMPLETED".equals(item.getPdaStatus())) {
continue; continue;
} }
List<CargoDiscardCarton> links = cartons.listApplication(application.getCustomerId(), application.getId()).stream() List<CargoDiscardCarton> links = repository.cartons(application.getCustomerId(), application.getId()).stream()
.filter(c -> c.getItemId().equals(item.getId())).toList(); .filter(c -> c.getItemId().equals(item.getId())).toList();
adjustments.discardPalletItem(application.getCustomerId(), application, item, links, // 未知箱唛对应关系时只按当前仓位冻结桶结算,不猜测被废弃的具体箱唛。
actuals.get(item.getId()), identity.operatorKey(), now); if ("INVENTORY".equals(item.getSourceType()) && stockService.actualQuantityEditable(item)) {
stockService.discard(application.getCustomerId(), identity.operatorKey(), application, item, actuals.get(item.getId()));
} else {
adjustments.discardPalletItem(application.getCustomerId(), application, item, links,
actuals.get(item.getId()), identity.operatorKey(), now);
}
item.setActualDiscardPackages(actuals.get(item.getId())); item.setPdaStatus("COMPLETED"); item.setActualDiscardPackages(actuals.get(item.getId())); item.setPdaStatus("COMPLETED");
item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); items.updateById(item); item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); repository.saveItem(item);
} }
task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).map(items::selectById) task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).map(repository::item)
.filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId).filter(Objects::nonNull).distinct() .filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId).filter(Objects::nonNull).distinct()
.forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId)); .forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId));
if (items.listApplication(application.getCustomerId(), application.getId()).stream() if (repository.items(application.getCustomerId(), application.getId()).stream()
.allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) { .allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) {
int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes( int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(
items.listApplication(application.getCustomerId(), application.getId()), true); repository.items(application.getCustomerId(), application.getId()), true);
application.setTotalPackages(actualTotal); application.setTotalPackages(actualTotal);
application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal))); application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal)));
discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED, discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED,
@@ -130,6 +130,7 @@ public class PdaCargoDiscardService {
} }
@Transactional @Transactional
/** 当前申请行锁内保存现场照片,已完成托盘禁止补写。 */
public CargoDiscardResponses.Photo uploadPhoto(PdaWarehouseIdentity identity, String discardNo, String palletNo, public CargoDiscardResponses.Photo uploadPhoto(PdaWarehouseIdentity identity, String discardNo, String palletNo,
long operatorUserId, org.springframework.web.multipart.MultipartFile file) { long operatorUserId, org.springframework.web.multipart.MultipartFile file) {
CargoDiscardApplication application = photoApplication(identity, discardNo, palletNo, true); CargoDiscardApplication application = photoApplication(identity, discardNo, palletNo, true);
@@ -140,19 +141,22 @@ public class PdaCargoDiscardService {
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
/** 读取绑定仓当前托盘的照片。 */
public List<CargoDiscardResponses.Photo> photos(PdaWarehouseIdentity identity, String discardNo, String palletNo) { public List<CargoDiscardResponses.Photo> photos(PdaWarehouseIdentity identity, String discardNo, String palletNo) {
return photoService.list(photoApplication(identity, discardNo, palletNo, false), palletNo.trim(), false); return photoService.list(photoApplication(identity, discardNo, palletNo, false), palletNo.trim(), false);
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
/** 下载当前申请和托盘拥有的照片。 */
public com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.DownloadedFile downloadPhoto( public com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.DownloadedFile downloadPhoto(
PdaWarehouseIdentity identity, String discardNo, String palletNo, long fileId) { PdaWarehouseIdentity identity, String discardNo, String palletNo, long fileId) {
return photoService.download(photoApplication(identity, discardNo, palletNo, false), palletNo.trim(), fileId, false); return photoService.download(photoApplication(identity, discardNo, palletNo, false), palletNo.trim(), fileId, false);
} }
/** 照片访问必须同时满足绑定仓和申请托盘范围。 */
private CargoDiscardApplication photoApplication(PdaWarehouseIdentity identity, String discardNo, String palletNo, boolean lock) { private CargoDiscardApplication photoApplication(PdaWarehouseIdentity identity, String discardNo, String palletNo, boolean lock) {
var warehouseId = String.valueOf(identity.overseasWarehouseId()); var warehouseId = String.valueOf(identity.overseasWarehouseId());
var application = lock ? applications.findNoByWarehouse(discardNo, warehouseId) : applications.readNoByWarehouse(discardNo, warehouseId); var application = lock ? repository.lockApplication(discardNo, warehouseId) : repository.readApplication(discardNo, warehouseId);
if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃任务不存在或不属于当前仓库"); if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃任务不存在或不属于当前仓库");
if (palletTasks(application).stream().noneMatch(t -> t.palletNo().equals(palletNo.trim()))) if (palletTasks(application).stream().noneMatch(t -> t.palletNo().equals(palletNo.trim())))
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单");
@@ -162,13 +166,13 @@ public class PdaCargoDiscardService {
private static final String LOOSE = "散箱"; private static final String LOOSE = "散箱";
/** 按实托汇总本单所选箱唛,退货件数与物理箱数分别呈现。 */ /** 按实托汇总本单所选箱唛,退货件数与物理箱数分别呈现。 */
private List<PdaCargoDiscardDtos.PalletTask> palletTasks(CargoDiscardApplication application) { private List<PdaCargoDiscardDtos.PalletTask> palletTasks(CargoDiscardApplication application) {
var allItems = items.listApplication(application.getCustomerId(), application.getId()); var allItems = repository.items(application.getCustomerId(), application.getId());
var grouped = allItems.stream().collect(Collectors.groupingBy(i -> physicalCode(application, i), LinkedHashMap::new, Collectors.toList())); var grouped = allItems.stream().collect(Collectors.groupingBy(i -> physicalCode(application, i), LinkedHashMap::new, Collectors.toList()));
return grouped.entrySet().stream().map(entry -> { return grouped.entrySet().stream().map(entry -> {
CargoDiscardItem first = entry.getValue().get(0); CargoDiscardItem first = entry.getValue().get(0);
var skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(i.getId(), i.getSkuCode(), i.getProductName(), var skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(i.getId(), i.getSkuCode(), i.getProductName(),
i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus(), i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus(),
isReturnItem(i) ? "件" : "箱")).toList(); isReturnItem(i) ? "件" : "箱", "INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i))).toList();
String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING"; String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING";
var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList(); var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList();
// 退货箱只代表本单选择的箱子,不能因为有托盘号就当成整托废弃。 // 退货箱只代表本单选择的箱子,不能因为有托盘号就当成整托废弃。
@@ -180,6 +184,7 @@ public class PdaCargoDiscardService {
status, skus, outboundService.outboundNo(application), cartonScan ? "CARTON" : "PALLET", marks); status, skus, outboundService.outboundNo(application), cartonScan ? "CARTON" : "PALLET", marks);
}).toList(); }).toList();
} }
/** 相关托盘只从客户库存事实读取,不从请求推断。 */
private String physicalCode(CargoDiscardApplication application, CargoDiscardItem item) { private String physicalCode(CargoDiscardApplication application, CargoDiscardItem item) {
String code; String code;
if ("INVENTORY".equals(item.getSourceType())) { if ("INVENTORY".equals(item.getSourceType())) {
@@ -187,31 +192,35 @@ public class PdaCargoDiscardService {
if (!application.getOverseasWarehouseId().equals(position.getOverseasWarehouseId())) throw conflict("废弃库存不属于当前仓库"); if (!application.getOverseasWarehouseId().equals(position.getOverseasWarehouseId())) throw conflict("废弃库存不属于当前仓库");
code = position.getPalletNo(); code = position.getPalletNo();
} else if (isReturnItem(item)) { } else if (isReturnItem(item)) {
var carton = returnCartons.selectById(item.getReturnCartonId()); var carton = repository.returnCarton(item.getReturnCartonId());
if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())) throw conflict("废弃明细关联退货箱不存在"); if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())) throw conflict("废弃明细关联退货箱不存在");
code = carton.getPalletCode(); code = carton.getPalletCode();
} else { } else {
var profile = profiles.selectById(item.getProfileId()); var profile = repository.profile(item.getProfileId());
if (profile == null || !application.getCustomerId().equals(profile.getCustomerId())) throw conflict("废弃明细关联托盘不存在"); if (profile == null || !application.getCustomerId().equals(profile.getCustomerId())) throw conflict("废弃明细关联托盘不存在");
code = profile.getPalletNo(); code = profile.getPalletNo();
} }
return code == null || code.isBlank() ? LOOSE : code.trim(); return code == null || code.isBlank() ? LOOSE : code.trim();
} }
/** 仅返回保存的真实箱唛,未知对应关系不补造箱码。 */
private List<String> cartonMarks(CargoDiscardApplication application, CargoDiscardItem item) { private List<String> cartonMarks(CargoDiscardApplication application, CargoDiscardItem item) {
if ("INVENTORY".equals(item.getSourceType())) return stockService.cartonMarks(item); if ("INVENTORY".equals(item.getSourceType())) return stockService.cartonMarks(item);
if (isReturnItem(item)) { if (isReturnItem(item)) {
var carton = returnCartons.selectById(item.getReturnCartonId()); var carton = repository.returnCarton(item.getReturnCartonId());
String mark = com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton); String mark = com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton);
return mark == null ? List.of() : List.of(mark); return mark == null ? List.of() : List.of(mark);
} }
return cartons.listApplication(application.getCustomerId(), application.getId()).stream() return repository.cartons(application.getCustomerId(), application.getId()).stream()
.filter(c -> Objects.equals(c.getItemId(), item.getId())).map(CargoDiscardCarton::getCartonMark).filter(Objects::nonNull).toList(); .filter(c -> Objects.equals(c.getItemId(), item.getId())).map(CargoDiscardCarton::getCartonMark).filter(Objects::nonNull).toList();
} }
/** 退货库存沿用独立按件结算规则。 */
private boolean isReturnItem(CargoDiscardItem item) { private boolean isReturnItem(CargoDiscardItem item) {
return "RETURN_CARTON".equals(item.getSourceType()) || item.getReturnCartonId() != null; return "RETURN_CARTON".equals(item.getSourceType()) || item.getReturnCartonId() != null;
} }
/** 取首个有效业务展示值。 */
private static String firstNonBlank(String first, String second) { private static String firstNonBlank(String first, String second) {
return first != null && !first.isBlank() ? first : second; return first != null && !first.isBlank() ? first : second;
} }
/** 返回可恢复业务冲突。 */
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
} }
@@ -12,8 +12,29 @@ import java.util.List;
public class CargoDiscardDimensionRepository { public class CargoDiscardDimensionRepository {
@Resource private CargoDiscardItemMapper items; @Resource private CargoDiscardItemMapper items;
@Resource private CargoDiscardDimensionMapper dimensions; @Resource private CargoDiscardDimensionMapper dimensions;
@Resource private com.fasterxml.jackson.databind.ObjectMapper objectMapper;
/** 读取一票客户隔离明细。 */ /** 读取一票客户隔离明细。 */
public List<CargoDiscardItem> items(String customer, Long application) { return items.listApplication(customer, application); } public List<CargoDiscardItem> items(String customer, Long application) { return items.listApplication(customer, application); }
/** 读取一票托盘身份,保留无事实行。 */ /** 读取一票托盘身份,保留无事实行。 */
public List<String> pallets(String customer, Long application) { return dimensions.palletNos(customer, application); } public List<String> pallets(String customer, Long application) { return dimensions.palletNos(customer, application); }
/** 完整箱级事实才可提供件数,缺箱或未知件数不得固定换算。 */
public Long pieces(String customer, CargoDiscardItem item) {
var values = dimensions.selectedCartonPieces(customer, item.getId());
if (values.isEmpty() || values.size() != item.getDiscardPackages()) return null;
long total = 0;
for (var value : values) {
if (value == null || value.isBlank()) return null;
try {
var contents = objectMapper.readTree(value);
if (!contents.isArray()) return null;
long selected = 0;
for (var content : contents) {
if (item.getSkuCode().equals(content.path("skuCode").asText())) selected += content.path("quantity").asLong();
}
if (selected <= 0) return null;
total += selected;
} catch (com.fasterxml.jackson.core.JsonProcessingException e) { return null; }
}
return total;
}
} }
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.discard.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.lastleg.discard.entity.*;
import com.baiyue.lastleg.discard.mapper.*;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 封装 PDA 废弃任务读取和作业快照持久化,保留绑定仓与申请行锁。 */
@Repository
public class CargoDiscardPdaRepository {
@Resource private CargoDiscardApplicationMapper applications;
@Resource private CargoDiscardItemMapper items;
@Resource private CargoDiscardCartonMapper cartons;
@Resource private DropshipOrderProfileMapper profiles;
@Resource private ReturnInventoryCartonMapper returnCartons;
/** 按已验证绑定仓读取已审核作业,客户归属由申请事实保留。 */
public List<CargoDiscardApplication> approved(String warehouseId) {
return applications.selectList(new LambdaQueryWrapper<CargoDiscardApplication>()
.eq(CargoDiscardApplication::getOverseasWarehouseId, warehouseId)
.eq(CargoDiscardApplication::getStatus, "APPROVED")
.orderByAsc(CargoDiscardApplication::getCreatedAt));
}
/** 写操作按申请串行,保证扣减、释放、实点结果及终态同事务。 */
public CargoDiscardApplication lockApplication(String no, String warehouse) { return applications.findNoByWarehouse(no, warehouse); }
/** 照片只读入口仍限制绑定仓。 */
public CargoDiscardApplication readApplication(String no, String warehouse) { return applications.readNoByWarehouse(no, warehouse); }
/** 按申请所属客户读取全部明细。 */
public List<CargoDiscardItem> items(String customer, Long application) { return items.listApplication(customer, application); }
/** 读取明细后由服务复验客户与申请。 */
public CargoDiscardItem item(Long id) { return items.selectById(id); }
/** 保存一次实际作业结果。 */
public void saveItem(CargoDiscardItem item) { items.updateById(item); }
/** 按客户和申请读取已选箱快照。 */
public List<CargoDiscardCarton> cartons(String customer, Long application) { return cartons.listApplication(customer, application); }
/** 读取原托盘档案供服务核验归属。 */
public DropshipOrderProfile profile(Long id) { return profiles.selectById(id); }
/** 读取退货箱档案供服务核验归属。 */
public ReturnInventoryCarton returnCarton(Long id) { return returnCartons.selectById(id); }
}
@@ -11,9 +11,10 @@ import org.springframework.stereotype.Repository;
public class CargoDiscardQuantityRepository { public class CargoDiscardQuantityRepository {
@Resource private CargoDiscardItemMapper items; @Resource private CargoDiscardItemMapper items;
/** 列表和详情均按所选实物箱展示;空明细保留申请自身快照。 */ /** 完工后列表和详情按实际箱数展示,作业前保留申请预留数量。 */
public int boxes(CargoDiscardApplication application) { public int boxes(CargoDiscardApplication application) {
var lines = items.listApplication(application.getCustomerId(), application.getId()); var lines = items.listApplication(application.getCustomerId(), application.getId());
return lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, false); boolean completed = "PDA_COMPLETED".equals(application.getStatus()) || "COMPLETED".equals(application.getStatus());
return lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, completed);
} }
} }
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.discard.repository;
import com.baiyue.lastleg.discard.entity.*;
import com.baiyue.lastleg.discard.mapper.*;
import com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow;
import com.baiyue.lastleg.inventory.entity.*;
import com.baiyue.lastleg.inventory.mapper.*;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 封装普通库存废弃的客户隔离读取、锁箱和申请快照保存。 */
@Repository
public class CargoDiscardStockRepository {
@Resource private CargoDiscardInventoryMapper stock;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@Resource private CargoDiscardItemMapper items;
@Resource private CargoDiscardCartonMapper links;
/** 读取客户当前可用且没有转运占用的库存仓位。 */
public List<InventoryPosition> candidates(String customer, String keyword) { return stock.genericPositions(customer, keyword); }
/** 读取余额,服务继续校验其客户与仓库归属。 */
public InventoryBalance balance(Long id) { return balances.selectById(id); }
/** 读取客户指定仓位。 */
public InventoryPosition position(String customer, Long id) { return positions.findTenantPosition(customer, id); }
/** 锁定源仓位以串行创建预留。 */
public InventoryPosition lockPosition(String customer, Long id) { return stock.lockGenericPosition(customer, id); }
/** 读取已有的真实箱唛与托盘对应事实。 */
public List<CargoDiscardStockCartonRow> cartons(String customer, Long position) { return stock.genericCartons(customer, position); }
/** 排除须走一件代发逐箱预留的仓位。 */
public boolean dropshipPosition(String customer, Long position) { return stock.isDropshipPosition(customer, position) > 0; }
/** 读取散箱实收流水中的箱码快照。 */
public List<String> looseMarks(String customer, Long position) { return stock.looseCartonMarks(customer, position); }
/** 保存废弃申请的库存来源明细。 */
public void saveItem(CargoDiscardItem item) { items.insert(item); }
/** 保存申请所选实物箱快照。 */
public void saveCarton(CargoDiscardCarton carton) { links.insert(carton); }
/** 读取申请全部所选箱,服务按当前明细筛选。 */
public List<CargoDiscardCarton> selectedCartons(String customer, Long application) { return links.listApplication(customer, application); }
/** 读取同申请各 SKU 的仓位和箱唛快照,用于按物理仓位消除重复库存命令。 */
public List<CargoDiscardItem> applicationItems(String customer, Long application) { return items.listApplication(customer, application); }
/** 普通实收箱用原状态条件更新,防止并发占用。 */
public int changeInboundCarton(String customer, Long id, String from, String to) { return stock.changeGenericCartonStatus(customer, id, from, to); }
/** 转运实收箱保留已清点状态语义。 */
public int changeTransferCarton(String customer, Long id, String from, String to) { return stock.changeTransferCartonStatus(customer, id, from, to); }
}
@@ -58,6 +58,7 @@ public class CargoDiscardOutboundService {
var snapshots = new java.util.ArrayList<OutboundPalletItem>(); var snapshots = new java.util.ArrayList<OutboundPalletItem>();
var palletCodes = new java.util.HashSet<String>(); var palletCodes = new java.util.HashSet<String>();
var returnBoxIds = new java.util.HashSet<Long>(); var returnBoxIds = new java.util.HashSet<Long>();
var inventoryBoxMarks = new java.util.HashSet<String>();
for (var line : lines) { for (var line : lines) {
boolean returned = line.getReturnCartonId() != null || "RETURN_CARTON".equals(line.getSourceType()); boolean returned = line.getReturnCartonId() != null || "RETURN_CARTON".equals(line.getSourceType());
String palletNo; String palletNo;
@@ -88,7 +89,7 @@ public class CargoDiscardOutboundService {
snapshot.setId(line.getId()); snapshot.setCustomerId(application.getCustomerId()); snapshot.setOutboundOrderId(outbound.getId()); snapshot.setId(line.getId()); snapshot.setCustomerId(application.getCustomerId()); snapshot.setOutboundOrderId(outbound.getId());
snapshot.setPalletNo(palletNo); snapshot.setSkuCode(line.getSkuCode()); snapshot.setInboundCode(line.getInboundCode()); snapshot.setPalletNo(palletNo); snapshot.setSkuCode(line.getSkuCode()); snapshot.setInboundCode(line.getInboundCode());
snapshot.setOrderRefs(application.getDiscardNo()); snapshot.setCartonMarks(cartonMarks); snapshot.setOrderRefs(application.getDiscardNo()); snapshot.setCartonMarks(cartonMarks);
snapshot.setPackageCount(returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages()); snapshot.setPackageCount(returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : physicalBoxes(line, inventoryBoxMarks));
snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK"); snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK");
if (returned) snapshot.setReturnSkuQuantity((long) line.getDiscardPackages()); if (returned) snapshot.setReturnSkuQuantity((long) line.getDiscardPackages());
snapshot.setVolumeWeightKg(BigDecimal.ZERO); snapshot.setVolumeCbm(BigDecimal.ZERO); snapshot.setCreatedAt(at); snapshot.setVolumeWeightKg(BigDecimal.ZERO); snapshot.setVolumeCbm(BigDecimal.ZERO); snapshot.setCreatedAt(at);
@@ -99,11 +100,13 @@ public class CargoDiscardOutboundService {
} }
/** 同步实物箱的拣货进度,退货 SKU 件数不进入箱数汇总。 */ /** 同步实物箱的拣货进度,退货 SKU 件数不进入箱数汇总。 */
/** 同步废弃实物出库投影,混装 SKU 明细不能重复累计物理箱。 */
public void sync(CargoDiscardApplication application) { public void sync(CargoDiscardApplication application) {
OutboundOrder outbound = outbounds.lockByIdempotency(application.getCustomerId(), key(application)); OutboundOrder outbound = outbounds.lockByIdempotency(application.getCustomerId(), key(application));
if (outbound == null) throw conflict("废弃出库单不存在,请联系平台核对审核结果"); if (outbound == null) throw conflict("废弃出库单不存在,请联系平台核对审核结果");
var lines = items.listApplication(application.getCustomerId(), application.getId()); var lines = items.listApplication(application.getCustomerId(), application.getId());
var returnBoxIds = new java.util.HashSet<Long>(); var returnBoxIds = new java.util.HashSet<Long>();
var inventoryBoxMarks = new java.util.HashSet<String>();
for (var snapshot : pallets.findByOutbound(application.getCustomerId(), outbound.getId())) { for (var snapshot : pallets.findByOutbound(application.getCustomerId(), outbound.getId())) {
var line = lines.stream().filter(i -> Objects.equals(i.getId(), snapshot.getId())).findFirst() var line = lines.stream().filter(i -> Objects.equals(i.getId(), snapshot.getId())).findFirst()
.orElseThrow(() -> conflict("废弃出库明细关联失效")); .orElseThrow(() -> conflict("废弃出库明细关联失效"));
@@ -114,11 +117,11 @@ public class CargoDiscardOutboundService {
throw conflict("废弃明细关联退货箱不存在"); throw conflict("废弃明细关联退货箱不存在");
snapshot.setCartonMarks(com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton)); snapshot.setCartonMarks(com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton));
} }
int boxes = returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages(); int boxes = returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : physicalBoxes(line, inventoryBoxMarks);
snapshot.setPackageCount(boxes); snapshot.setPackageCount(boxes);
if ("COMPLETED".equals(line.getPdaStatus())) { if ("COMPLETED".equals(line.getPdaStatus())) {
snapshot.setPickedPackageCount(returned ? (Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) > 0 ? boxes : 0) snapshot.setPickedPackageCount(returned ? (Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) > 0 ? boxes : 0)
: Objects.requireNonNullElse(line.getActualDiscardPackages(), 0)); : "INVENTORY".equals(line.getSourceType()) && !stockService.cartonMarks(line).isEmpty() ? boxes : Objects.requireNonNullElse(line.getActualDiscardPackages(), 0));
snapshot.setStatus("PICKED"); snapshot.setStatus("PICKED");
} }
if (CargoDiscardService.CANCELLED.equals(application.getStatus())) snapshot.setStatus("CANCELLED"); if (CargoDiscardService.CANCELLED.equals(application.getStatus())) snapshot.setStatus("CANCELLED");
@@ -134,5 +137,11 @@ public class CargoDiscardOutboundService {
outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, "OUTBOUNDED".equals(status))); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, "OUTBOUNDED".equals(status)));
outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound); outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound);
} }
/** 保留逐 SKU 来源行,已投影过的同一实箱在后续行记零箱。 */
private int physicalBoxes(com.baiyue.lastleg.discard.entity.CargoDiscardItem line, java.util.Set<String> seen) {
if (!"INVENTORY".equals(line.getSourceType())) return line.getDiscardPackages();
var marks = stockService.cartonMarks(line);
return marks.isEmpty() ? line.getDiscardPackages() : (int)marks.stream().filter(mark -> seen.add(line.getInboundCode() + ":" + mark)).count();
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
} }
@@ -165,7 +165,10 @@ public class CargoDiscardService {
if (input.cartonIds() != null) { if (input.cartonIds() != null) {
if (input.cartonIds().isEmpty() || input.cartonIds().size() != input.discardPackages()) throw badRequest("请选择具体废弃箱子,箱数须与选择一致"); if (input.cartonIds().isEmpty() || input.cartonIds().size() != input.discardPackages()) throw badRequest("请选择具体废弃箱子,箱数须与选择一致");
String source = input.inventoryPositionId() != null ? "INVENTORY" : "DROPSHIP"; String source = input.inventoryPositionId() != null ? "INVENTORY" : "DROPSHIP";
for (Long cartonId : input.cartonIds()) if (!requestedBoxes.add(source + ":" + cartonId)) throw badRequest("同一个箱子不能重复废弃"); for (Long cartonId : input.cartonIds()) {
boolean added = requestedBoxes.add(source + ":" + cartonId);
if (!added && input.inventoryPositionId() == null) throw badRequest("同一个箱子不能重复废弃");
}
} }
int sources = (returnSource ? 1 : 0) + (input.orderId() != null ? 1 : 0) + (input.inventoryPositionId() != null ? 1 : 0); int sources = (returnSource ? 1 : 0) + (input.orderId() != null ? 1 : 0) + (input.inventoryPositionId() != null ? 1 : 0);
if (sources != 1) { if (sources != 1) {
@@ -202,7 +205,7 @@ public class CargoDiscardService {
throw conflict("One cargo discard application cannot span operating warehouses"); throw conflict("One cargo discard application cannot span operating warehouses");
} }
// 退货明细为件数,整箱只在全部 SKU 校验完成后按箱 ID 计一次。 // 退货明细为件数,整箱只在全部 SKU 校验完成后按箱 ID 计一次。
if (!returnSource) total = Math.addExact(total, input.discardPackages()); if (!returnSource && input.cartonIds() == null) total = Math.addExact(total, input.discardPackages());
} }
for (Long cartonId : returnCartonCache.keySet()) { for (Long cartonId : returnCartonCache.keySet()) {
@@ -214,6 +217,7 @@ public class CargoDiscardService {
if (!availableSkus.equals(requestedSkus)) throw conflict("目前不支持部分废弃,必须选择退货箱内全部 SKU 库存"); if (!availableSkus.equals(requestedSkus)) throw conflict("目前不支持部分废弃,必须选择退货箱内全部 SKU 库存");
} }
total = Math.addExact(total, returnCartonCache.size()); total = Math.addExact(total, returnCartonCache.size());
total = Math.addExact(total, requestedBoxes.size());
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId); if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId);
CargoDiscardApplication application = new CargoDiscardApplication(); CargoDiscardApplication application = new CargoDiscardApplication();
@@ -236,9 +240,10 @@ public class CargoDiscardService {
// 废弃申请独立承载废弃流程,不创建尾程订单。 // 废弃申请独立承载废弃流程,不创建尾程订单。
Set<Long> reservedReturnCartons = new LinkedHashSet<>(); Set<Long> reservedReturnCartons = new LinkedHashSet<>();
stockService.reserveAll(customerId, operatorId, application, request.items(), now);
for (CargoDiscardRequests.Item input : request.items()) { for (CargoDiscardRequests.Item input : request.items()) {
if (input.inventoryPositionId() != null) { if (input.inventoryPositionId() != null) {
stockService.reserve(customerId, operatorId, application, input, now); // 普通库存已按完整混装箱批量预留。
} else if (input.returnCartonId() != null) { } else if (input.returnCartonId() != null) {
reserveReturn(customerId, application, returnCartonCache.get(input.returnCartonId()), input, now, reserveReturn(customerId, application, returnCartonCache.get(input.returnCartonId()), input, now,
reservedReturnCartons); reservedReturnCartons);
@@ -513,10 +518,15 @@ public class CargoDiscardService {
/** 废弃列表与详情按物理箱汇总,并按每箱单价计算应展示费用。 */ /** 废弃列表与详情按物理箱汇总,并按每箱单价计算应展示费用。 */
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value, Map<String, String> names) { private CargoDiscardResponses.Summary summary(CargoDiscardApplication value, Map<String, String> names) {
int boxes = quantities.boxes(value); int boxes = quantities.boxes(value);
var lines = items.listApplication(value.getCustomerId(), value.getId());
var skuCodes = lines.stream().map(CargoDiscardItem::getSkuCode).filter(s -> s != null && !s.isBlank()).distinct().sorted().toList();
int requested = lines.isEmpty() ? value.getTotalPackages() : com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false);
Integer actual = lines.stream().anyMatch(i -> i.getActualDiscardPackages() != null)
? com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, true) : null;
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(), return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), boxes, value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), boxes,
value.getFeePerPackage() == null ? value.getTotalFee() : value.getFeePerPackage().multiply(BigDecimal.valueOf(boxes)), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(), value.getFeePerPackage() == null ? value.getTotalFee() : value.getFeePerPackage().multiply(BigDecimal.valueOf(boxes)), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt()); value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt(), skuCodes, skuCodes.size(), requested, actual);
} }
private void history(String customerId, Long applicationId, String from, String to, String operatorId, private void history(String customerId, Long applicationId, String from, String to, String operatorId,
@@ -32,8 +32,14 @@ public class CargoDiscardStatisticsService {
for (var line : lines) { for (var line : lines) {
if (line.getSkuCode() != null && !line.getSkuCode().isBlank()) skus.add(line.getSkuCode()); if (line.getSkuCode() != null && !line.getSkuCode().isBlank()) skus.add(line.getSkuCode());
if (line.getReturnCartonId() == null) { if (line.getReturnCartonId() == null) {
piecesKnown = false; var selectedPieces = dimensions.pieces(application.getCustomerId(), line);
if (line.getActualDiscardPackages() != null) actualPiecesKnown = false; if (selectedPieces == null) piecesKnown = false;
else pieces += selectedPieces;
if (line.getActualDiscardPackages() != null) {
if (line.getActualDiscardPackages() == 0) { /* 实际未废弃商品为零,不推测箱规。 */ }
else if (selectedPieces != null && line.getActualDiscardPackages() == line.getDiscardPackages()) actualPieces += selectedPieces;
else actualPiecesKnown = false;
}
} else { } else {
pieces += line.getDiscardPackages(); pieces += line.getDiscardPackages();
if (line.getActualDiscardPackages() != null) actualPieces += line.getActualDiscardPackages(); if (line.getActualDiscardPackages() != null) actualPieces += line.getActualDiscardPackages();
@@ -2,11 +2,9 @@ package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.discard.dto.*; import com.baiyue.lastleg.discard.dto.*;
import com.baiyue.lastleg.discard.entity.*; import com.baiyue.lastleg.discard.entity.*;
import com.baiyue.lastleg.discard.mapper.*;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.*; import com.baiyue.lastleg.inventory.entity.*;
import com.baiyue.lastleg.inventory.mapper.*;
import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -16,129 +14,233 @@ import org.springframework.web.server.ResponseStatusException;
import java.util.*; import java.util.*;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** Stock eligibility is based on physical available positions, independent of the source document type. */ /** 根据实际可用仓位校验废弃库存,精确选箱与无对应关系实点分别结算。 */
@Service @Service
public class CargoDiscardStockService { public class CargoDiscardStockService {
@Resource private CargoDiscardInventoryMapper stock; @Resource private com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository repository;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@Resource private InventoryCommandService commands; @Resource private InventoryCommandService commands;
@Resource private CargoDiscardItemMapper items;
@Resource private CargoDiscardCartonMapper links;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
/** 展示可用库存及相关托盘,箱唛完整时才提供逐箱选择。 */
public List<CargoDiscardResponses.Candidate> candidates(String customer, String keyword) { public List<CargoDiscardResponses.Candidate> candidates(String customer, String keyword) {
return stock.genericPositions(customer, keyword).stream().map(position -> { return repository.candidates(customer, keyword).stream().map(position -> {
var balance = balances.selectById(position.getBalanceId()); var balance = repository.balance(position.getBalanceId());
if (balance == null || !customer.equals(balance.getCustomerId())) throw conflict("库存余额不存在"); if (balance == null || !customer.equals(balance.getCustomerId())) throw conflict("库存余额不存在");
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages())); long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()));
var cartons = stock.genericCartons(customer, position.getId()); var cartons = repository.cartons(customer, position.getId());
boolean complete = cartons.size() == available && cartons.stream().allMatch(c -> c.getCartonMark() != null && !c.getCartonMark().isBlank()) boolean complete = completeCartons(cartons, available);
&& cartons.stream().map(c -> c.getCartonMark()).distinct().count() == cartons.size();
var boxes = complete ? cartons.stream().map(c -> new CargoDiscardResponses.Box(c.getId(), c.getCartonMark(), position.getPalletNo(), var boxes = complete ? cartons.stream().map(c -> new CargoDiscardResponses.Box(c.getId(), c.getCartonMark(), position.getPalletNo(),
List.of(new CargoDiscardResponses.CandidateSku(c.getSkuCode(), balance.getProductNameZh(), 1)))).toList() : List.<CargoDiscardResponses.Box>of(); contents(c, balance.getProductNameZh()))).toList() : List.<CargoDiscardResponses.Box>of();
return new CargoDiscardResponses.Candidate(null, "INVENTORY", null, null, return new CargoDiscardResponses.Candidate(null, "INVENTORY", null, null,
balance.getInboundCode(), balance.getInboundOrderNo(), balance.getOverseasWarehouseId(), balance.getInboundCode(), balance.getInboundOrderNo(), balance.getOverseasWarehouseId(),
balance.getOverseasWarehouseName(), position.getCreatedAt(), available, balance.getOverseasWarehouseName(), position.getCreatedAt(), available,
List.of(new CargoDiscardResponses.CandidateSku(sku(balance), balance.getProductNameZh(), available)), complete ? summarizeSkus(cartons, balance) : List.of(new CargoDiscardResponses.CandidateSku(sku(balance), balance.getProductNameZh(), available)),
position.getId(), position.getPalletNo(), boxes, complete, position.getId(), position.getPalletNo(), boxes, complete,
complete ? null : "当前库存缺少完整箱唛与托盘对应记录,可按全部库存废弃"); complete ? null : "缺少完整箱唛与托盘对应记录,将预留所选库存,由仓库核对相关托盘并填写实际废弃箱数,未废弃部分释放");
}).filter(c -> c.availablePackages() > 0).toList(); }).filter(c -> c.availablePackages() > 0).toList();
} }
/** 校验客户、仓库、SKU、预留上限与具体箱子的归属。 */
public InventoryBalance validate(String customer, CargoDiscardRequests.Item request) { public InventoryBalance validate(String customer, CargoDiscardRequests.Item request) {
var position = position(customer, request.inventoryPositionId()); var position = position(customer, request.inventoryPositionId());
var balance = balances.selectById(position.getBalanceId()); var balance = repository.balance(position.getBalanceId());
if (balance == null || !customer.equals(balance.getCustomerId()) if (balance == null || !customer.equals(balance.getCustomerId())
|| !Objects.equals(position.getOverseasWarehouseId(), balance.getOverseasWarehouseId())) || !Objects.equals(position.getOverseasWarehouseId(), balance.getOverseasWarehouseId()))
throw conflict("库存不属于当前客户或仓库"); throw conflict("库存不属于当前客户或仓库");
if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存");
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages())); long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()));
if (available <= 0 || request.discardPackages() <= 0 || request.discardPackages() > available) throw conflict("所选废弃库存数量不足"); if (available <= 0 || request.discardPackages() <= 0 || request.discardPackages() > available) throw conflict("所选废弃库存数量不足");
if (request.cartonIds() != null) { if (request.cartonIds() != null) {
if (request.cartonIds().size() != request.discardPackages() || new HashSet<>(request.cartonIds()).size() != request.cartonIds().size()) if (request.cartonIds().size() != request.discardPackages() || new HashSet<>(request.cartonIds()).size() != request.cartonIds().size())
throw conflict("废弃箱子数量与所选箱子不一致或重复"); throw conflict("废弃箱子数量与所选箱子不一致或重复");
var cartons = stock.genericCartons(customer, position.getId()); var cartons = repository.cartons(customer, position.getId());
var ids = cartons.stream().map(c -> c.getId()).collect(java.util.stream.Collectors.toSet()); var ids = cartons.stream().map(c -> c.getId()).collect(java.util.stream.Collectors.toSet());
if (cartons.size() != available || cartons.stream().anyMatch(c -> c.getCartonMark() == null || c.getCartonMark().isBlank()) || cartons.stream().map(c -> c.getCartonMark()).distinct().count() != cartons.size() || !ids.containsAll(request.cartonIds())) throw conflict("箱唛库存已变化或不属于所选托盘,请刷新后重选"); if (cartons.size() != available || cartons.stream().anyMatch(c -> c.getCartonMark() == null || c.getCartonMark().isBlank()) || cartons.stream().map(c -> c.getCartonMark()).distinct().count() != cartons.size() || !ids.containsAll(request.cartonIds())) throw conflict("箱唛库存已变化或不属于所选托盘,请刷新后重选");
} else if (request.discardPackages() != available) throw conflict("部分废弃必须选择具体箱子,不能只填写数量"); if (cartons.stream().filter(c -> request.cartonIds().contains(c.getId())).anyMatch(c -> contents(c, null).stream().noneMatch(s -> request.skuCode().trim().equals(s.skuCode())))) throw conflict("SKU 不属于所选箱子");
if (stock.isDropshipPosition(customer, position.getId()) > 0) throw conflict("一件代发库存必须按真实箱唛选择,不能绕过箱级预留"); } else {
if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存");
if (request.discardPackages() != available) throw conflict("部分废弃必须选择具体箱子,不能只填写数量");
}
if (repository.dropshipPosition(customer, position.getId())) throw conflict("一件代发库存必须按真实箱唛选择,不能绕过箱级预留");
return balance; return balance;
} }
/** 预留所选实收箱并保存箱号快照,转运箱从已清点状态进入待废弃。 */ /** 预留所选实收箱并保存箱号快照,转运箱从已清点状态进入待废弃。 */
public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) { public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) {
if (stock.lockGenericPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在"); if (repository.lockPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在");
var balance = validate(customer, request); var balance = validate(customer, request);
reserveValidated(customer, operator, application, request, at, balance, repository.cartons(customer, request.inventoryPositionId()), new HashSet<>(), new HashSet<>());
}
/** 整箱混装必须包含全部 SKU 仓位;先锁仓位并校验全部事实,再按实物箱仅锁定一次。 */
public void reserveAll(String customer, String operator, CargoDiscardApplication application, List<CargoDiscardRequests.Item> requests, LocalDateTime at) {
var stocks = requests.stream().filter(r -> r.inventoryPositionId() != null).toList();
stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().forEach(id -> {
if (repository.lockPosition(customer, id) == null) throw conflict("库存仓位不存在");
});
var balances = new HashMap<Long, InventoryBalance>();
var cartons = new HashMap<Long, List<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow>>();
var selectedSkus = new HashMap<String, Set<String>>();
for (var request : stocks) {
balances.put(request.inventoryPositionId(), validate(customer, request));
var facts = repository.cartons(customer, request.inventoryPositionId());
cartons.put(request.inventoryPositionId(), facts);
for (var carton : facts) if (request.cartonIds() == null || request.cartonIds().contains(carton.getId()))
selectedSkus.computeIfAbsent(carton.getSourceType() + ":" + carton.getId(), k -> new HashSet<>()).add(request.skuCode().trim());
}
for (var request : stocks) for (var carton : cartons.get(request.inventoryPositionId())) {
if (request.cartonIds() != null && !request.cartonIds().contains(carton.getId())) continue;
var required = contents(carton, null).stream().map(CargoDiscardResponses.CandidateSku::skuCode).collect(java.util.stream.Collectors.toSet());
if (required.size() > 1 && request.cartonIds() == null) throw conflict("混装箱必须选择具体箱子及箱内全部 SKU 库存");
if (!selectedSkus.get(carton.getSourceType() + ":" + carton.getId()).containsAll(required)) throw conflict("混装箱必须选择箱内全部 SKU 库存");
}
var lockedBoxes = new HashSet<String>();
var heldBoxes = new HashSet<String>();
for (var request : stocks) reserveValidated(customer, operator, application, request, at,
balances.get(request.inventoryPositionId()), cartons.get(request.inventoryPositionId()), lockedBoxes, heldBoxes);
}
/** 使用本事务变更箱状态前的完整事实,为每个 SKU 分别预留并保存相同箱唛快照。 */
private void reserveValidated(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at,
InventoryBalance balance, List<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow> availableCartons, Set<String> lockedBoxes, Set<String> heldBoxes) {
var position = position(customer, request.inventoryPositionId()); var position = position(customer, request.inventoryPositionId());
var line = new CargoDiscardItem(); line.setId(IdWorker.getId()); line.setApplicationId(application.getId()); var line = new CargoDiscardItem(); line.setId(IdWorker.getId()); line.setApplicationId(application.getId());
line.setCustomerId(customer); line.setSourceType("INVENTORY"); line.setInventoryPositionId(position.getId()); line.setCustomerId(customer); line.setSourceType("INVENTORY"); line.setInventoryPositionId(position.getId());
line.setInboundCode(balance.getInboundCode()); line.setSkuCode(sku(balance)); line.setProductName(balance.getProductNameZh()); line.setInboundCode(balance.getInboundCode()); line.setSkuCode(request.skuCode().trim()); line.setProductName(sku(balance).equals(line.getSkuCode()) ? balance.getProductNameZh() : null);
line.setAvailablePackagesSnapshot(Math.toIntExact(Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages())))); line.setDiscardPackages(request.discardPackages()); line.setCreatedAt(at); line.setAvailablePackagesSnapshot(availableCartons.isEmpty() ? Math.toIntExact(Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()))) : availableCartons.size()); line.setDiscardPackages(request.discardPackages()); line.setCreatedAt(at);
var availableCartons = stock.genericCartons(customer, position.getId());
var selected = request.cartonIds() == null var selected = request.cartonIds() == null
? (availableCartons.size() == request.discardPackages() ? availableCartons : List.<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow>of()) ? (completeCartons(availableCartons, request.discardPackages()) ? availableCartons : List.<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow>of())
: availableCartons.stream().filter(c -> request.cartonIds().contains(c.getId())).toList(); : availableCartons.stream().filter(c -> request.cartonIds().contains(c.getId())).toList();
if (!selected.isEmpty()) { if (!selected.isEmpty()) {
for (var carton : selected) { for (var carton : selected) {
if (!lockedBoxes.add(carton.getSourceType() + ":" + carton.getId())) continue;
if (changeBox(customer, carton.getId(), carton.getSourceType(), "TRANSFER".equals(carton.getSourceType()) ? "COUNTED" : "AVAILABLE", "DISCARD_PENDING") != 1) throw conflict("箱子已被其他作业占用,请刷新后重选"); if (changeBox(customer, carton.getId(), carton.getSourceType(), "TRANSFER".equals(carton.getSourceType()) ? "COUNTED" : "AVAILABLE", "DISCARD_PENDING") != 1) throw conflict("箱子已被其他作业占用,请刷新后重选");
var link = new CargoDiscardCarton(); link.setId(IdWorker.getId()); link.setCustomerId(customer); link.setApplicationId(application.getId()); var link = new CargoDiscardCarton(); link.setId(IdWorker.getId()); link.setCustomerId(customer); link.setApplicationId(application.getId());
link.setItemId(line.getId()); link.setSourceType(carton.getSourceType()); link.setCartonId(carton.getId()); link.setCartonMark(carton.getCartonMark()); link.setCreatedAt(at); links.insert(link); link.setItemId(line.getId()); link.setSourceType(carton.getSourceType()); link.setCartonId(carton.getId()); link.setCartonMark(carton.getCartonMark()); link.setCreatedAt(at); repository.saveCarton(link);
} }
snapshotMarks(line, selected.stream().map(c -> c.getCartonMark()).toList()); snapshotMarks(line, selected.stream().map(c -> c.getCartonMark()).toList());
line.setCartonScanRequired(request.discardPackages() < value(position.getAvailablePackages()) + value(position.getHoldPackages()) + value(position.getAllocatedPackages()) + value(position.getPickedPackages())); line.setCartonScanRequired(request.discardPackages() < value(position.getAvailablePackages()) + value(position.getHoldPackages()) + value(position.getAllocatedPackages()) + value(position.getPickedPackages()));
} else if (position.getPalletNo() == null || position.getPalletNo().isBlank()) { } else if (position.getPalletNo() == null || position.getPalletNo().isBlank()) {
var marks = stock.looseCartonMarks(customer, position.getId()); var marks = repository.looseMarks(customer, position.getId());
if (marks.size() != request.discardPackages() || marks.stream().distinct().count() != marks.size()) throw conflict("散箱库存缺少完整箱唛,请先核对实物箱级入库记录"); if (marks.size() != request.discardPackages() || marks.stream().distinct().count() != marks.size()) throw conflict("散箱库存缺少完整箱唛,请先核对实物箱级入库记录");
snapshotMarks(line, marks); snapshotMarks(line, marks);
} }
commands.hold(customer, operator, move(application, line, request.discardPackages(), "HOLD")); int physical = selected.isEmpty() ? request.discardPackages() : (int)selected.stream().filter(c -> heldBoxes.add(position.getId() + ":" + c.getSourceType() + ":" + c.getId())).count();
items.insert(line); if (physical > 0) commands.hold(customer, operator, move(application, line, physical, "HOLD"));
repository.saveItem(line);
} }
/** 同一仓位可能承载混装箱,完整 SKU 候选来自实箱而不是库存余额的首 SKU。 */
private List<CargoDiscardResponses.CandidateSku> summarizeSkus(List<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow> cartons, InventoryBalance balance) {
var boxes = new LinkedHashMap<String, Long>(); var pieces = new HashMap<String, Long>(); var unknown = new HashSet<String>();
for (var carton : cartons) for (var content : contents(carton, null)) {
boxes.merge(content.skuCode(), 1L, Math::addExact);
if (content.availablePieces() == null) unknown.add(content.skuCode()); else pieces.merge(content.skuCode(), content.availablePieces(), Math::addExact);
}
return boxes.entrySet().stream().map(e -> new CargoDiscardResponses.CandidateSku(e.getKey(), sku(balance).equals(e.getKey()) ? balance.getProductNameZh() : null,
e.getValue(), unknown.contains(e.getKey()) ? null : pieces.get(e.getKey()))).toList();
}
/** 箱数、箱唛和唯一性均完整时,才允许将库存对应到具体箱子。 */
private boolean completeCartons(List<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow> cartons, long count) {
return cartons.size() == count && cartons.stream().allMatch(c -> c.getCartonMark() != null && !c.getCartonMark().isBlank())
&& cartons.stream().map(c -> c.getCartonMark()).distinct().count() == cartons.size();
}
/** 转运实收 JSON 提供完整 SKU 与件数;无 JSON 的单 SKU 来源保留箱数单位。 */
private List<CargoDiscardResponses.CandidateSku> contents(com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow carton, String productName) {
if (carton.getSkuItemsJson() == null || carton.getSkuItemsJson().isBlank())
return List.of(new CargoDiscardResponses.CandidateSku(carton.getSkuCode(), productName, 1));
try {
var values = objectMapper.readTree(carton.getSkuItemsJson());
if (!values.isArray()) throw conflict("箱内 SKU 事实不完整");
var quantities = new LinkedHashMap<String, Long>();
for (var value : values) {
String code = value.path("skuCode").asText().trim(); long pieces = value.path("quantity").asLong();
if (code.isEmpty() || pieces <= 0) throw conflict("箱内 SKU 事实不完整");
quantities.merge(code, pieces, Math::addExact);
}
if (quantities.isEmpty()) throw conflict("箱内 SKU 事实不完整");
return quantities.entrySet().stream().map(e -> new CargoDiscardResponses.CandidateSku(e.getKey(), Objects.equals(e.getKey(), carton.getSkuCode()) ? productName : null, 1, e.getValue())).toList();
} catch (com.fasterxml.jackson.core.JsonProcessingException e) { throw conflict("箱内 SKU 事实无法读取"); }
}
/** 驳回或取消时恢复所选箱状态并释放本次冻结数量。 */
public void release(String customer, CargoDiscardApplication application, CargoDiscardItem line) { public void release(String customer, CargoDiscardApplication application, CargoDiscardItem line) {
changeSelectedCartons(customer, application, line, "AVAILABLE"); changeSelectedCartons(customer, application, line, "AVAILABLE");
commands.release(customer, application.getCreatedBy(), move(application, line, line.getDiscardPackages(), "RELEASE")); int physical = physicalPackages(customer, application, line);
if (physical > 0) commands.release(customer, application.getCreatedBy(), move(application, line, physical, "RELEASE"));
} }
/** 无逐箱对应关系时按仓库实点扣冻结库存,未废弃余量在同一作业事务释放。 */
@org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void discard(String customer, String operator, CargoDiscardApplication application, CargoDiscardItem line, int actual) { public void discard(String customer, String operator, CargoDiscardApplication application, CargoDiscardItem line, int actual) {
if (actual != line.getDiscardPackages()) throw conflict("必须完成本次申请所选全部箱子,不能漏出"); if (actual < 0 || actual > line.getDiscardPackages()) throw conflict("实际废弃箱数不能超过申请预留箱数");
changeSelectedCartons(customer, application, line, "DISCARDED"); if (!actualQuantityEditable(line) && actual != line.getDiscardPackages())
commands.discardHeld(customer, operator, move(application, line, actual, "DISCARD")); throw conflict("必须完成本次申请所选全部箱子,不能漏出");
// 已选择具体箱子的作业仍按箱唛结算,不能按数量猜测哪些箱已被废弃。
if (!actualQuantityEditable(line)) changeSelectedCartons(customer, application, line, "DISCARDED");
int physical = actualQuantityEditable(line) ? actual : physicalPackages(customer, application, line);
if (physical > 0) commands.discardHeld(customer, operator, move(application, line, physical, "DISCARD"));
int remaining = line.getDiscardPackages() - actual;
if (remaining > 0) commands.release(customer, operator, move(application, line, remaining, "ACTUAL_RELEASE"));
} }
private int changeBox(String customer, Long cartonId, String source, String from, String to) { /** 同仓位同箱只由首个 SKU 明细执行库存命令;其他 SKU 保存件数事实,不重复扣物理箱桶。 */
if ("TRANSFER".equals(source)) return stock.changeTransferCartonStatus(customer, cartonId, from, to); private int physicalPackages(String customer, CargoDiscardApplication application, CargoDiscardItem line) {
if (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确"); var marks = cartonMarks(line);
return stock.changeGenericCartonStatus(customer, cartonId, from, to); if (marks.isEmpty()) return line.getDiscardPackages();
var previous = new HashSet<String>();
for (var other : repository.applicationItems(customer, application.getId())) {
if (Objects.equals(other.getInventoryPositionId(), line.getInventoryPositionId()) && other.getId() != null && line.getId() != null && other.getId() < line.getId()) previous.addAll(cartonMarks(other));
}
return (int)marks.stream().filter(mark -> !previous.contains(mark)).count();
} }
/** 只有未保存具体箱唛的普通仓位库存允许仓库按实点箱数完成。 */
public boolean actualQuantityEditable(CargoDiscardItem line) {
return "INVENTORY".equals(line.getSourceType()) && line.getInventoryPositionId() != null
&& cartonMarks(line).isEmpty() && !Boolean.TRUE.equals(line.getCartonScanRequired());
}
/** 按箱来源执行带原状态条件的变更。 */
private int changeBox(String customer, Long cartonId, String source, String from, String to) {
if ("TRANSFER".equals(source)) return repository.changeTransferCarton(customer, cartonId, from, to);
if (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确");
return repository.changeInboundCarton(customer, cartonId, from, to);
}
/** 保存本次选箱事实,后续作业不得按数量猜测箱唛。 */
private void snapshotMarks(CargoDiscardItem line, List<String> marks) { private void snapshotMarks(CargoDiscardItem line, List<String> marks) {
try { line.setCartonMarksJson(objectMapper.writeValueAsString(marks)); } try { line.setCartonMarksJson(objectMapper.writeValueAsString(marks)); }
catch (Exception e) { throw new IllegalStateException("Cannot snapshot carton marks", e); } catch (Exception e) { throw new IllegalStateException("Cannot snapshot carton marks", e); }
} }
/** 按真实箱来源完成废弃或释放,转运箱释放后恢复已清点状态。 */ /** 仅变更当前申请明细保存的箱子,转运箱释放后恢复已清点状态。 */
private void changeSelectedCartons(String customer, CargoDiscardApplication application, CargoDiscardItem line, String status) { private void changeSelectedCartons(String customer, CargoDiscardApplication application, CargoDiscardItem line, String status) {
for (var link : links.listApplication(customer, application.getId())) { for (var link : repository.selectedCartons(customer, application.getId())) {
if (Objects.equals(link.getItemId(), line.getId()) && changeBox(customer, link.getCartonId(), link.getSourceType(), "DISCARD_PENDING", "TRANSFER".equals(link.getSourceType()) && "AVAILABLE".equals(status) ? "COUNTED" : status) != 1) if (Objects.equals(link.getItemId(), line.getId()) && changeBox(customer, link.getCartonId(), link.getSourceType(), "DISCARD_PENDING", "TRANSFER".equals(link.getSourceType()) && "AVAILABLE".equals(status) ? "COUNTED" : status) != 1)
throw conflict("预留箱子的库存状态发生变化"); throw conflict("预留箱子的库存状态发生变化");
} }
} }
/** 库存命令使用申请和明细构成的稳定来源与幂等键。 */
private InventoryCommandRequests.Move move(CargoDiscardApplication application, CargoDiscardItem line, int count, String action) { private InventoryCommandRequests.Move move(CargoDiscardApplication application, CargoDiscardItem line, int count, String action) {
String cycle = "CARGO_DISCARD:" + application.getId() + ":" + line.getId(); String cycle = "CARGO_DISCARD:" + application.getId() + ":" + line.getId();
return new InventoryCommandRequests.Move(line.getInventoryPositionId(), count, application.getDiscardNo(), return new InventoryCommandRequests.Move(line.getInventoryPositionId(), count, application.getDiscardNo(),
null, cycle, cycle + ":" + action, "货物废弃 " + action); null, cycle, cycle + ":" + action, "货物废弃 " + action);
} }
/** 读取并核对客户指定库存仓位。 */
public InventoryPosition position(String customer, Long id) { public InventoryPosition position(String customer, Long id) {
var position = positions.findTenantPosition(customer, id); var position = repository.position(customer, id);
if (position == null) throw conflict("废弃库存仓位不存在或不属于当前客户"); if (position == null) throw conflict("废弃库存仓位不存在或不属于当前客户");
return position; return position;
} }
/** 只解析申请保存的箱唛,缺失对应关系保留为空。 */
public List<String> cartonMarks(CargoDiscardItem line) { public List<String> cartonMarks(CargoDiscardItem line) {
if (line.getCartonMarksJson() == null) return List.of(); if (line.getCartonMarksJson() == null) return List.of();
try { return objectMapper.readValue(line.getCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}); } try { return objectMapper.readValue(line.getCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}); }
catch (Exception e) { throw new IllegalStateException("Cannot read carton marks", e); } catch (Exception e) { throw new IllegalStateException("Cannot read carton marks", e); }
} }
/** 无 SKU 来源保留未指定标记,不猜测商品。 */
private static String sku(InventoryBalance balance) { return balance.getSkuCode() == null || balance.getSkuCode().isBlank() ? "UNSPECIFIED" : balance.getSkuCode(); } private static String sku(InventoryBalance balance) { return balance.getSkuCode() == null || balance.getSkuCode().isBlank() ? "UNSPECIFIED" : balance.getSkuCode(); }
/** 空库存桶按零处理。 */
private static long value(Long value) { return value == null ? 0 : value; } private static long value(Long value) { return value == null ? 0 : value; }
/** 返回可恢复的业务冲突。 */
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
} }
@@ -5,11 +5,6 @@ import java.util.List;
/** BOL按真实明细客户逐行读取整车,不依赖已移除的主单客户头。 */ /** BOL按真实明细客户逐行读取整车,不依赖已移除的主单客户头。 */
@Mapper @Mapper
public interface DispatchBolMapper { public interface DispatchBolMapper {
/** 旧客户详情入口沿用整车查询,但只返回该客户自己的BOL明细。 */
default List<com.baiyue.lastleg.dispatch.dto.DispatchResponse.BolLine> findByDispatchOrder(String customerId, Long dispatchOrderId) {
return findWholeDispatch(dispatchOrderId).stream().filter(line -> java.util.Objects.equals(customerId,line.customerId())).map(line -> new com.baiyue.lastleg.dispatch.dto.DispatchResponse.BolLine(line.containerNo(),line.palletNo(),line.fbaWarehouseCode(),line.destinationWarehouseName(),line.warehouseNo(),line.fbaShipmentId(),line.poReferenceNo(),line.packageCount())).toList();
}
@Select("SELECT di.customer_id AS customerId,di.destination_type AS destinationType,di.delivery_address AS deliveryAddress,di.postal_code AS postalCode," + @Select("SELECT di.customer_id AS customerId,di.destination_type AS destinationType,di.delivery_address AS deliveryAddress,di.postal_code AS postalCode," +
"COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo," + "COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo," +
"MAX(NULLIF(di.pallet_no,'')) AS palletNo," + "MAX(NULLIF(di.pallet_no,'')) AS palletNo," +
@@ -20,7 +20,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository; import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository;
import com.baiyue.lastleg.dispatch.service.DispatchItemSourceService; import com.baiyue.lastleg.dispatch.port.DispatchSourceAllocation;
@Service @Service
public class DispatchPickOrderAttributionService { public class DispatchPickOrderAttributionService {
@@ -39,7 +39,7 @@ public class DispatchPickOrderAttributionService {
/** 第四来源分支链接真实冻结贡献,全部归属、履约计数和库存跟随原扫码事务提交。 */ /** 第四来源分支链接真实冻结贡献,全部归属、履约计数和库存跟随原扫码事务提交。 */
@org.springframework.transaction.annotation.Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) @org.springframework.transaction.annotation.Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void attributeRelabel(String customerId,DispatchPdaPick pick,Long orderId,List<DispatchItemSourceService.Allocation> allocations) { public void attributeRelabel(String customerId,DispatchPdaPick pick,Long orderId,List<DispatchSourceAllocation> allocations) {
long packages=0,quantity=0;var seen=new java.util.HashSet<Long>(); long packages=0,quantity=0;var seen=new java.util.HashSet<Long>();
for(var allocation:allocations) { for(var allocation:allocations) {
var source=allocation.dispatchItemSourceId()==null?null:relabelSources.find(allocation.dispatchItemSourceId()); var source=allocation.dispatchItemSourceId()==null?null:relabelSources.find(allocation.dispatchItemSourceId());
@@ -0,0 +1,4 @@
package com.baiyue.lastleg.dispatch.port;
/** 一次真实拣货消费的冻结来源额度;件数未知时保留空,不接受请求伪造来源身份。 */
public record DispatchSourceAllocation(Long dispatchItemSourceId, long packages, Long productQuantity) { }
@@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.entity.*; import com.baiyue.lastleg.dispatch.entity.*;
import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper; import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper;
import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository; import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository;
import com.baiyue.lastleg.dispatch.port.DispatchSourceAllocation;
import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort; import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -23,9 +24,6 @@ public class DispatchItemSourceService {
/** 来源事实经提供者读取,冻结贡献及已消费数经本域仓储读取。 */ /** 来源事实经提供者读取,冻结贡献及已消费数经本域仓储读取。 */
public DispatchItemSourceService(DispatchItemSourceRepository repository,RelabelDispatchSourcePort relabel,ObjectMapper json){this.repository=repository;this.relabel=relabel;this.json=json;} public DispatchItemSourceService(DispatchItemSourceRepository repository,RelabelDispatchSourcePort relabel,ObjectMapper json){this.repository=repository;this.relabel=relabel;this.json=json;}
/** packages是本次实际消费量,件数未知保留空;不是请求可伪造的来源身份。 */
public record Allocation(Long dispatchItemSourceId,long packages,Long productQuantity) { }
/** 主单原事务已锁齐全部来源与库存;所有当前成员一起核对,仅为新增明细追加不可变快照。 */ /** 主单原事务已锁齐全部来源与库存;所有当前成员一起核对,仅为新增明细追加不可变快照。 */
@Transactional(propagation=Propagation.MANDATORY) @Transactional(propagation=Propagation.MANDATORY)
public void capture(DispatchAggregate root,List<DispatchItem> all,List<DispatchItem> added,LocalDateTime at) { public void capture(DispatchAggregate root,List<DispatchItem> all,List<DispatchItem> added,LocalDateTime at) {
@@ -108,7 +106,7 @@ public class DispatchItemSourceService {
/** 本客户本实托的全部剩余贡献必须累计核对HOLD,逐item单独足够不能证明共享position总量足够。 */ /** 本客户本实托的全部剩余贡献必须累计核对HOLD,逐item单独足够不能证明共享position总量足够。 */
@Transactional(propagation=Propagation.MANDATORY) @Transactional(propagation=Propagation.MANDATORY)
public List<Allocation> allocate(long dispatch,DispatchItem item,long positionId,long packages,long productQuantity,String cartonMark,List<DispatchSourceMapper.Stock> actualStock) { public List<DispatchSourceAllocation> allocate(long dispatch,DispatchItem item,long positionId,long packages,long productQuantity,String cartonMark,List<DispatchSourceMapper.Stock> actualStock) {
var all=repository.all(dispatch);var consumed=repository.pickedAll(List.of(dispatch)).stream().collect(Collectors.toMap(p->p.sourceId(),p->p)); var all=repository.all(dispatch);var consumed=repository.pickedAll(List.of(dispatch)).stream().collect(Collectors.toMap(p->p.sourceId(),p->p));
var expected=new HashMap<Long,Long>(); var expected=new HashMap<Long,Long>();
for(var source:all)if(Objects.equals(item.getCustomerId(),source.getCustomerId()) && Objects.equals(item.getWarehousePalletId(),source.getWarehousePalletId())) { for(var source:all)if(Objects.equals(item.getCustomerId(),source.getCustomerId()) && Objects.equals(item.getWarehousePalletId(),source.getWarehousePalletId())) {
@@ -127,14 +125,14 @@ public class DispatchItemSourceService {
for(var row:rows){long left=row.getPackageCount()-(consumed.containsKey(row.getId())?consumed.get(row.getId()).packages():0);remaining.put(row.getId(),left);total=Math.addExact(total,left);} for(var row:rows){long left=row.getPackageCount()-(consumed.containsKey(row.getId())?consumed.get(row.getId()).packages():0);remaining.put(row.getId(),left);total=Math.addExact(total,left);}
if(packages<=0 || packages>total)throw conflict("实际扫码超过本换标来源本位置的剩余箱数"); if(packages<=0 || packages>total)throw conflict("实际扫码超过本换标来源本位置的剩余箱数");
if(rows.size()>1 && packages!=total)throw conflict("本位置含多条换标贡献,请扫描具体箱唛后确认部分箱数"); if(rows.size()>1 && packages!=total)throw conflict("本位置含多条换标贡献,请扫描具体箱唛后确认部分箱数");
var result=new ArrayList<Allocation>();long knownQuantity=0; var result=new ArrayList<DispatchSourceAllocation>();long knownQuantity=0;
for(var row:rows)if(row.getProductQuantity()!=null){long used=consumed.containsKey(row.getId())?consumed.get(row.getId()).productQuantity():0;long left=row.getProductQuantity()-used;if(left<0)throw conflict("换标实际件数超过来源贡献");knownQuantity=Math.addExact(knownQuantity,left);} for(var row:rows)if(row.getProductQuantity()!=null){long used=consumed.containsKey(row.getId())?consumed.get(row.getId()).productQuantity():0;long left=row.getProductQuantity()-used;if(left<0)throw conflict("换标实际件数超过来源贡献");knownQuantity=Math.addExact(knownQuantity,left);}
if(rows.size()>1) { if(rows.size()>1) {
if(productQuantity!=knownQuantity)throw conflict("多来源件数不能推算,请扫描明确箱唛并确认实际件数"); if(productQuantity!=knownQuantity)throw conflict("多来源件数不能推算,请扫描明确箱唛并确认实际件数");
for(var row:rows){Long quantity=row.getProductQuantity()==null?null:row.getProductQuantity()-(consumed.containsKey(row.getId())?consumed.get(row.getId()).productQuantity():0);result.add(new Allocation(row.getId(),remaining.get(row.getId()),quantity));} for(var row:rows){Long quantity=row.getProductQuantity()==null?null:row.getProductQuantity()-(consumed.containsKey(row.getId())?consumed.get(row.getId()).productQuantity():0);result.add(new DispatchSourceAllocation(row.getId(),remaining.get(row.getId()),quantity));}
} else { } else {
var row=rows.get(0);if(row.getProductQuantity()!=null && (productQuantity>knownQuantity || packages==total && productQuantity!=knownQuantity))throw conflict("实际扫码件数与本来源剩余件数不一致"); var row=rows.get(0);if(row.getProductQuantity()!=null && (productQuantity>knownQuantity || packages==total && productQuantity!=knownQuantity))throw conflict("实际扫码件数与本来源剩余件数不一致");
result.add(new Allocation(row.getId(),packages,row.getProductQuantity()==null && productQuantity==0?null:productQuantity)); result.add(new DispatchSourceAllocation(row.getId(),packages,row.getProductQuantity()==null && productQuantity==0?null:productQuantity));
} }
return List.copyOf(result); return List.copyOf(result);
} }
@@ -65,7 +65,7 @@ public class DispatchPickingService {
boolean nativeReturn="RETURN_RELABEL".equals(item.getSourceStage()); boolean nativeReturn="RETURN_RELABEL".equals(item.getSourceStage());
if(nativeReturn?request.positionId()!=null:request.positionId()==null)throw bad(nativeReturn?"退货换标请扫描来源箱唛":"普通入仓请选本托实际库存仓位"); if(nativeReturn?request.positionId()!=null:request.positionId()==null)throw bad(nativeReturn?"退货换标请扫描来源箱唛":"普通入仓请选本托实际库存仓位");
long pickId=IdWorker.getId();Long returnCartonId=null;boolean ordinaryRelabel=!nativeReturn && "RELABEL_ORDER".equals(item.getSourceBusinessType()); long pickId=IdWorker.getId();Long returnCartonId=null;boolean ordinaryRelabel=!nativeReturn && "RELABEL_ORDER".equals(item.getSourceBusinessType());
List<DispatchItemSourceService.Allocation> relabelAllocations=List.of(); List<DispatchSourceAllocation> relabelAllocations=List.of();
if(nativeReturn) { if(nativeReturn) {
attribution.prepareNativeReturn(item.getCustomerId(),item.getOrderId()); attribution.prepareNativeReturn(item.getCustomerId(),item.getOrderId());
returnCartonId=returns.pick(item,request.cartonMark(),request.packages(),request.productQuantity(),pickId,number).getId(); returnCartonId=returns.pick(item,request.cartonMark(),request.packages(),request.productQuantity(),pickId,number).getId();
@@ -75,7 +75,9 @@ public record LastlegOrderSummary(
LocalDateTime updatedAt, LocalDateTime updatedAt,
@Schema(description = "同一主单关联的全部换标子单及各自状态,按创建时间排序") @Schema(description = "同一主单关联的全部换标子单及各自状态,按创建时间排序")
List<RelabelSubOrder> relabelApplications, List<RelabelSubOrder> relabelApplications,
BusinessSource businessSource BusinessSource businessSource,
@Schema(description = "仓储转运专属预报、历史实收及当前箱托数量;其他业务为空")
com.baiyue.lastleg.transfer.dto.TransferQuantityView warehouseTransferQuantity
) { ) {
@Schema(description = "从正式单据关联读取的商业来源;与履约类型 orderType 独立") @Schema(description = "从正式单据关联读取的商业来源;与履约类型 orderType 独立")
public record BusinessSource(String businessType, String label, String businessOrderNo, String externalBookingNo) { } public record BusinessSource(String businessType, String label, String businessOrderNo, String externalBookingNo) { }
@@ -1,13 +1,14 @@
package com.baiyue.lastleg.fleet.pickup; package com.baiyue.lastleg.fleet.pickup;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import org.apache.ibatis.annotations.*; import org.apache.ibatis.annotations.*;
import java.util.List; import java.util.List;
/** 取货持久化只访问 Lastleg 自有库,客户与原业务号始终成对使用。 */ /** 取货持久化只访问 Lastleg 自有库,客户与原业务号始终成对使用。 */
@Mapper @Mapper
public interface FleetPickupMapper extends BaseMapper<FleetPickupTask> { public interface FleetPickupMapper extends BaseMapper<FleetPickupTask> {
String SOURCE_COLUMNS = "id,customer_id,transfer_no,inbound_code,lastleg_order_no,operating_warehouse_id,"
+ "delivery_address,pickup_contact_name,pickup_contact_phone,package_count,status,delivery_method";
String ELIGIBLE = " FROM ll_warehouse_transfer_order w LEFT JOIN ll_fleet_pickup_task p ON p.transfer_order_id=w.id " String ELIGIBLE = " FROM ll_warehouse_transfer_order w LEFT JOIN ll_fleet_pickup_task p ON p.transfer_order_id=w.id "
+ "WHERE w.delivery_method='WAREHOUSE_PICKUP' AND w.transfer_no=w.inbound_code " + "WHERE w.delivery_method='WAREHOUSE_PICKUP' AND w.transfer_no=w.inbound_code "
+ "AND w.status='WAIT_PDA_RECEIVING' AND (p.transfer_order_id IS NULL OR p.trip_no IS NULL) " + "AND w.status='WAIT_PDA_RECEIVING' AND (p.transfer_order_id IS NULL OR p.trip_no IS NULL) "
@@ -21,14 +22,11 @@ public interface FleetPickupMapper extends BaseMapper<FleetPickupTask> {
@Select("SELECT COUNT(*)" + ELIGIBLE) @Select("SELECT COUNT(*)" + ELIGIBLE)
long countCandidates(@Param("keyword") String keyword); long countCandidates(@Param("keyword") String keyword);
/** 锁定源订单,串行化补箱、清点与取货任务关联。 */ /** 锁定源订单,串行化补箱、清点与取货任务关联。 */
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number} FOR UPDATE") @Select("SELECT " + SOURCE_COLUMNS + " FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number} FOR UPDATE")
WarehouseTransferOrder lockSource(@Param("customer") String customer,@Param("number") String number); FleetPickupSource lockSource(@Param("customer") String customer,@Param("number") String number);
/** 读取明确客户下的源订单,不接收来自客户端的客户范围推断。 */ /** 读取明确客户下的源订单,不接收来自客户端的客户范围推断。 */
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number}") @Select("SELECT " + SOURCE_COLUMNS + " FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number}")
WarehouseTransferOrder source(@Param("customer") String customer,@Param("number") String number); FleetPickupSource source(@Param("customer") String customer,@Param("number") String number);
/** PDA 作业仍按已绑定海外仓锁定原单。 */
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE operating_warehouse_id=#{warehouse} AND transfer_no=#{number} FOR UPDATE")
WarehouseTransferOrder lockPdaSource(@Param("warehouse") String warehouse,@Param("number") String number);
/** 读取取货任务,原订单锁负责同票并发互斥。 */ /** 读取取货任务,原订单锁负责同票并发互斥。 */
@Select("SELECT * FROM ll_fleet_pickup_task WHERE transfer_order_id=#{id}") @Select("SELECT * FROM ll_fleet_pickup_task WHERE transfer_order_id=#{id}")
FleetPickupTask task(@Param("id") long id); FleetPickupTask task(@Param("id") long id);
@@ -2,7 +2,6 @@ package com.baiyue.lastleg.fleet.pickup;
import com.baiyue.lastleg.fleet.trip.FleetTripEntity; import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
import com.baiyue.lastleg.fleet.trip.FleetTripMapper; import com.baiyue.lastleg.fleet.trip.FleetTripMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
@@ -18,9 +17,7 @@ public class FleetPickupRepository {
/** 查询匹配需求总数。 */ /** 查询匹配需求总数。 */
public long count(String keyword) { return mapper.countCandidates(keyword); } public long count(String keyword) { return mapper.countCandidates(keyword); }
/** 读取或锁定客户原单。 */ /** 读取或锁定客户原单。 */
public WarehouseTransferOrder source(String customer,String number,boolean lock) { return lock?mapper.lockSource(customer,number):mapper.source(customer,number); } public FleetPickupSource source(String customer,String number,boolean lock) { return lock?mapper.lockSource(customer,number):mapper.source(customer,number); }
/** 按 PDA 仓库范围锁定原单。 */
public WarehouseTransferOrder pdaSource(String warehouse,String number) { return mapper.lockPdaSource(warehouse,number); }
/** 读取原单唯一取货记录。 */ /** 读取原单唯一取货记录。 */
public FleetPickupTask task(long id) { return mapper.task(id); } public FleetPickupTask task(long id) { return mapper.task(id); }
/** 读取车次全部取货成员。 */ /** 读取车次全部取货成员。 */
@@ -6,7 +6,6 @@ import com.baiyue.lastleg.fleet.driver.FleetWarehouseDirectory;
import com.baiyue.lastleg.fleet.reporting.TripOperationsEvidenceGateway; import com.baiyue.lastleg.fleet.reporting.TripOperationsEvidenceGateway;
import com.baiyue.lastleg.fleet.trip.*; import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@@ -59,7 +58,7 @@ public class FleetPickupService {
var sources=request.sources().stream().sorted(Comparator.comparing(FleetPickupModels.Source::customerId) var sources=request.sources().stream().sorted(Comparator.comparing(FleetPickupModels.Source::customerId)
.thenComparing(FleetPickupModels.Source::transferNo)).toList(); .thenComparing(FleetPickupModels.Source::transferNo)).toList();
if(new HashSet<>(sources).size()!=sources.size())throw bad("同一取货单不能重复选择"); if(new HashSet<>(sources).size()!=sources.size())throw bad("同一取货单不能重复选择");
var orders=new ArrayList<WarehouseTransferOrder>(); var orders=new ArrayList<FleetPickupSource>();
for(var source:sources) { for(var source:sources) {
var order=source(source.customerId(),source.transferNo(),true);eligible(order); var order=source(source.customerId(),source.transferNo(),true);eligible(order);
// 相同请求并发等待原单锁后,允许返回第一次已经成功的车次。 // 相同请求并发等待原单锁后,允许返回第一次已经成功的车次。
@@ -107,18 +106,10 @@ public class FleetPickupService {
if(!"WAREHOUSE_PICKUP".equals(source.getDeliveryMethod()) || !source.getTransferNo().equals(source.getInboundCode()))return null; if(!"WAREHOUSE_PICKUP".equals(source.getDeliveryMethod()) || !source.getTransferNo().equals(source.getInboundCode()))return null;
return project(source,repository.task(source.getId())); return project(source,repository.task(source.getId()));
} }
/** PDA 开始作业前校验已送仓,防止把司机取货冒充仓库实收。 */
public WarehouseTransferOrder requireMeasuringSource(String warehouse,String number) {
var source=repository.pdaSource(warehouse,number);
if(source==null)throw missing("仓储转运单不存在");
if(!"WAIT_PDA_RECEIVING".equals(source.getStatus()))throw conflict("当前转运单不允许测量");
requireArrived(source);
return source;
}
/** 已有原单行锁的清点、照片和完成入口复用相同到仓门禁。 */ /** 已有原单行锁的清点、照片和完成入口复用相同到仓门禁。 */
public void requireArrived(WarehouseTransferOrder source) { public void requireArrived(Long sourceId,String deliveryMethod,String transferNo,String inboundCode) {
if("WAREHOUSE_PICKUP".equals(source.getDeliveryMethod()) && source.getTransferNo().equals(source.getInboundCode())) { if("WAREHOUSE_PICKUP".equals(deliveryMethod) && transferNo.equals(inboundCode)) {
var task=repository.task(source.getId()); var task=repository.task(sourceId);
if(task==null || !"ARRIVED".equals(task.getStatus()))throw conflict("该单为仓库上门取货,请先完成车辆取货送仓"); if(task==null || !"ARRIVED".equals(task.getStatus()))throw conflict("该单为仓库上门取货,请先完成车辆取货送仓");
} }
} }
@@ -198,7 +189,7 @@ public class FleetPickupService {
var trip=pickupTrip(task.getTripNo(),false);return files.download(trip.getId(),fileId); var trip=pickupTrip(task.getTripNo(),false);return files.download(trip.getId(),fileId);
} }
/** 组装运输进度,车次资源修改后自然回显最新分配。 */ /** 组装运输进度,车次资源修改后自然回显最新分配。 */
private FleetPickupModels.Task project(WarehouseTransferOrder source,FleetPickupTask task) { private FleetPickupModels.Task project(FleetPickupSource source,FleetPickupTask task) {
var trip=task==null || task.getTripNo()==null?null:repository.trip(task.getTripNo(),false); var trip=task==null || task.getTripNo()==null?null:repository.trip(task.getTripNo(),false);
String status=task==null || task.getTripNo()==null?("PENDING_REVIEW".equals(source.getStatus()) || "DRAFT".equals(source.getStatus())?"PENDING_REVIEW":"WAITING"):task.getStatus(); String status=task==null || task.getTripNo()==null?("PENDING_REVIEW".equals(source.getStatus()) || "DRAFT".equals(source.getStatus())?"PENDING_REVIEW":"WAITING"):task.getStatus();
return new FleetPickupModels.Task(source.getCustomerId(),source.getTransferNo(),source.getLastlegOrderNo(),source.getOperatingWarehouseId(), return new FleetPickupModels.Task(source.getCustomerId(),source.getTransferNo(),source.getLastlegOrderNo(),source.getOperatingWarehouseId(),
@@ -215,11 +206,11 @@ public class FleetPickupService {
if(task==null || !trip.getTripNo().equals(task.getTripNo()))throw missing("当前车次没有该取货订单");return task; if(task==null || !trip.getTripNo().equals(task.getTripNo()))throw missing("当前车次没有该取货订单");return task;
} }
/** 按客户与单号读取原订单。 */ /** 按客户与单号读取原订单。 */
private WarehouseTransferOrder source(String customer,String number,boolean lock) { private FleetPickupSource source(String customer,String number,boolean lock) {
var source=repository.source(customer,number,lock);if(source==null)throw missing("仓储转运单不存在");return source; var source=repository.source(customer,number,lock);if(source==null)throw missing("仓储转运单不存在");return source;
} }
/** 原业务资格每次执行复核,禁止未审、自送、库存转运和已完成测量单进入。 */ /** 原业务资格每次执行复核,禁止未审、自送、库存转运和已完成测量单进入。 */
private static void eligible(WarehouseTransferOrder order) { private static void eligible(FleetPickupSource order) {
if(!"WAREHOUSE_PICKUP".equals(order.getDeliveryMethod()) || !order.getTransferNo().equals(order.getInboundCode()) if(!"WAREHOUSE_PICKUP".equals(order.getDeliveryMethod()) || !order.getTransferNo().equals(order.getInboundCode())
|| !"WAIT_PDA_RECEIVING".equals(order.getStatus()))throw conflict("该单不是已审核待收货的仓库上门取货单"); || !"WAIT_PDA_RECEIVING".equals(order.getStatus()))throw conflict("该单不是已审核待收货的仓库上门取货单");
if(blank(order.getDeliveryAddress())==null || blank(order.getPickupContactName())==null || blank(order.getPickupContactPhone())==null) if(blank(order.getDeliveryAddress())==null || blank(order.getPickupContactName())==null || blank(order.getPickupContactPhone())==null)
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.fleet.pickup;
import lombok.Data;
/** 车队只读取货资格和展示所需来源摘要,不共享转运领域实体或写入原订单。 */
@Data
public class FleetPickupSource {
private Long id;
private String customerId;
private String transferNo;
private String inboundCode;
private String lastlegOrderNo;
private String operatingWarehouseId;
private String deliveryAddress;
private String pickupContactName;
private String pickupContactPhone;
private Integer packageCount;
private String status;
private String deliveryMethod;
}
@@ -35,6 +35,16 @@ public class InventoryQueryController {
private static final String BASE_PATH = "/api/platform/v1/last-leg/inventory"; private static final String BASE_PATH = "/api/platform/v1/last-leg/inventory";
@Resource private InventoryQueryService service; @Resource private InventoryQueryService service;
@Resource private com.baiyue.lastleg.inventory.query.service.InventoryPieceService pieces;
/** 完整筛选读取当前可用件数、箱内全部SKU及未知箱数,不改变原有箱数契约。 */
@GetMapping(BASE_PATH + "/piece-projection")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
public ApiResponse<com.baiyue.lastleg.inventory.query.dto.InventoryPieceProjection> pieceProjection(
@AuthenticationPrincipal AuthenticatedUser user,@RequestParam(required=false) String customerId,
@Valid InventoryQueryRequests.BalanceQuery query) {
return ApiResponse.success(pieces.project(platformScope(user,customerId),query));
}
@GetMapping(BASE_PATH + "/summary") @GetMapping(BASE_PATH + "/summary")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.inventory.query.dto;
import java.util.List;
/** 当前严格可用件数与完整混箱SKU内容;不完整映射保持未知,不覆盖原有箱数API。 */
public record InventoryPieceProjection(Long availablePieceCount,long knownAvailablePieceCount,long unknownCartonCount,
long availableCartonCount,long availablePalletCount,List<Group> groups) {
/** 与通用库存按客户、仓、目的仓、记账SKU分组对齐,箱内SKU不重复累计物理箱。 */
public record Group(String customerId,String warehouseId,String destinationCode,String anchorSkuCode,
Long availablePieceCount,long knownAvailablePieceCount,long unknownCartonCount,long availableCartonCount,
long availablePalletCount,List<Sku> skuContents,List<Carton> cartons) { }
/** 箱内实际SKU件数。 */
public record Sku(String skuCode,long quantity) { }
/** 保留箱唛及所在仓位,混箱SKU共享同一物理箱。 */
public record Carton(String cartonMark,Long inventoryPositionId,Long palletId,Long quantity,List<Sku> items) { }
}
@@ -0,0 +1,73 @@
package com.baiyue.lastleg.inventory.query.mapper;
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 从原箱清点事实及实际目的仓签收定位箱内SKU,不按首SKU或箱规换算件数。 */
@Mapper
public interface InventoryPieceMapper {
String POSITION_CTE = "WITH balances AS (SELECT * FROM ll_inventory_balance " + InventoryQueryMapper.BALANCE_FILTER + """
), occupied AS (
SELECT DISTINCT t.customer_id,t.operating_warehouse_id warehouse_id,t.inbound_code,
COALESCE(NULLIF(c.customer_carton_mark,''),c.carton_mark) carton_mark
FROM ll_warehouse_transfer_order t JOIN ll_warehouse_transfer_carton c
ON c.transfer_order_id=t.id AND c.customer_id=t.customer_id
-- 出库已扣减来源箱,后续签收生成目的仓库存,不再把同一原箱当作待出库预留。
WHERE t.transfer_no&lt;&gt;t.inbound_code AND t.status NOT IN
('DRAFT','REJECTED','CANCELLED','OUTBOUNDED','PARTIALLY_RECEIVED','RECEIVED')
), raw_positions AS (SELECT p.* FROM ll_inventory_position p JOIN balances b
ON b.id=p.balance_id AND b.customer_id=p.customer_id WHERE p.available_packages>0),
located AS (
SELECT p.id position_id,c.id carton_id,c.carton_mark,c.counted_quantity,c.sku_items_json
FROM raw_positions p JOIN balances b ON b.id=p.balance_id AND b.customer_id=p.customer_id
JOIN ll_warehouse_transfer_order t ON t.customer_id=b.customer_id AND t.inbound_order_id=b.inbound_order_id
AND t.operating_warehouse_id=b.overseas_warehouse_id AND t.transfer_no=t.inbound_code
JOIN ll_warehouse_transfer_pallet tp ON tp.transfer_order_id=t.id AND tp.customer_id=t.customer_id AND tp.warehouse_pallet_id=p.pallet_id
JOIN ll_warehouse_transfer_carton c ON c.transfer_order_id=t.id AND c.customer_id=t.customer_id
AND c.transfer_pallet_id=tp.id AND c.status='COUNTED'
JOIN ll_warehouse_transfer_item i ON i.id=c.transfer_item_id AND i.customer_id=c.customer_id AND (b.sku_code='MIXED' OR i.sku_code=b.sku_code)
UNION
SELECT p.id,c.id,c.carton_mark,c.counted_quantity,c.sku_items_json
FROM raw_positions p JOIN balances b ON b.id=p.balance_id AND b.customer_id=p.customer_id
JOIN ll_warehouse_transfer_receipt r ON r.destination_inventory_position_id=p.id AND r.customer_id=p.customer_id
JOIN ll_warehouse_transfer_inventory_allocation a ON a.id=r.allocation_id AND a.customer_id=r.customer_id AND a.status='RECEIVED'
JOIN ll_inventory_position sp ON sp.id=a.inventory_position_id AND sp.customer_id=a.customer_id
JOIN ll_warehouse_transfer_order outgoing ON outgoing.id=a.transfer_order_id AND outgoing.customer_id=a.customer_id
JOIN ll_warehouse_transfer_carton chosen ON chosen.transfer_order_id=outgoing.id AND chosen.customer_id=outgoing.customer_id
JOIN ll_warehouse_transfer_order origin ON origin.customer_id=outgoing.customer_id AND origin.inbound_code=outgoing.inbound_code
AND origin.operating_warehouse_id=outgoing.operating_warehouse_id AND origin.transfer_no=origin.inbound_code
JOIN ll_warehouse_transfer_carton c ON c.transfer_order_id=origin.id AND c.customer_id=origin.customer_id
AND c.carton_mark=COALESCE(NULLIF(chosen.customer_carton_mark,''),chosen.carton_mark) AND c.status='SHIPPED'
JOIN ll_warehouse_transfer_pallet tp ON tp.id=c.transfer_pallet_id AND tp.customer_id=c.customer_id AND tp.warehouse_pallet_id=sp.pallet_id
JOIN ll_warehouse_transfer_item i ON i.id=c.transfer_item_id AND i.customer_id=c.customer_id AND (b.sku_code='MIXED' OR i.sku_code=b.sku_code)
), reservations AS (
SELECT p.id position_id,COUNT(DISTINCT l.carton_id) reserved_count
FROM raw_positions p JOIN balances b ON b.id=p.balance_id AND b.customer_id=p.customer_id
JOIN located l ON l.position_id=p.id JOIN occupied o ON o.customer_id=b.customer_id
AND o.warehouse_id=b.overseas_warehouse_id AND o.inbound_code=b.inbound_code AND o.carton_mark=l.carton_mark
GROUP BY p.id
), positions AS (
SELECT p.*,GREATEST(0,p.available_packages-COALESCE(r.reserved_count,0)) free_packages
FROM raw_positions p LEFT JOIN reservations r ON r.position_id=p.id
)
SELECT p.id positionId,p.balance_id balanceId,b.customer_id customerId,b.overseas_warehouse_id warehouseId,
b.destination_warehouse_code destinationCode,b.sku_code anchorSkuCode,p.pallet_id palletId,
p.free_packages availablePackages,l.carton_id cartonId,l.carton_mark cartonMark,
l.counted_quantity countedQuantity,l.sku_items_json skuItemsJson
FROM positions p JOIN balances b ON b.id=p.balance_id AND b.customer_id=p.customer_id
LEFT JOIN located l ON l.position_id=p.id AND NOT EXISTS (
SELECT 1 FROM occupied o WHERE o.customer_id=b.customer_id AND o.warehouse_id=b.overseas_warehouse_id
AND o.inbound_code=b.inbound_code AND o.carton_mark=l.carton_mark)
WHERE p.free_packages>0
ORDER BY p.id,l.carton_id
""";
/** 完整筛选取箱级投影;页码/每页大小不参与查询。 */
@Select("<script>" + POSITION_CTE + "</script>")
List<PieceRow> rows(@Param("customerId") String customerId,@Param("q") InventoryQueryRequests.BalanceQuery query);
/** 每个当前可用仓位及其匹配箱事实;未知箱以空cartonId表达。 */
record PieceRow(Long positionId,Long balanceId,String customerId,String warehouseId,String destinationCode,
String anchorSkuCode,Long palletId,long availablePackages,Long cartonId,String cartonMark,Long countedQuantity,String skuItemsJson) { }
}
@@ -24,7 +24,7 @@ public interface InventoryQueryMapper {
"<if test='q.inboundOrderNo != null and q.inboundOrderNo != \"\"'> AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')</if>" + "<if test='q.inboundOrderNo != null and q.inboundOrderNo != \"\"'> AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')</if>" +
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" + "<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=ll_inventory_balance.id AND l.customer_id=ll_inventory_balance.customer_id AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))</if>" + "<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=ll_inventory_balance.id AND l.customer_id=ll_inventory_balance.customer_id AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))</if>" +
"<if test='q.skuCode != null and q.skuCode != \"\"'> AND sku_code LIKE CONCAT('%',#{q.skuCode},'%')</if>" + "<if test='q.skuCode != null and q.skuCode != \"\"'> AND (sku_code LIKE CONCAT('%',#{q.skuCode},'%') OR EXISTS (SELECT 1 FROM ll_warehouse_transfer_order tq JOIN ll_warehouse_transfer_carton cq ON cq.transfer_order_id=tq.id AND cq.customer_id=tq.customer_id WHERE tq.customer_id=ll_inventory_balance.customer_id AND tq.inbound_order_id=ll_inventory_balance.inbound_order_id AND tq.operating_warehouse_id=ll_inventory_balance.overseas_warehouse_id AND tq.transfer_no=tq.inbound_code AND cq.status IN ('COUNTED','DISCARD_PENDING') AND EXISTS (SELECT 1 FROM ll_warehouse_transfer_item iq WHERE iq.id=cq.transfer_item_id AND iq.customer_id=cq.customer_id AND (ll_inventory_balance.sku_code='MIXED' OR iq.sku_code=ll_inventory_balance.sku_code)) AND JSON_SEARCH(cq.sku_items_json,'one',CONCAT('%',#{q.skuCode},'%'),NULL,'$[*].skuCode') IS NOT NULL) OR EXISTS (SELECT 1 FROM ll_inventory_position dp JOIN ll_warehouse_transfer_receipt rr ON rr.destination_inventory_position_id=dp.id AND rr.customer_id=dp.customer_id JOIN ll_warehouse_transfer_inventory_allocation aa ON aa.id=rr.allocation_id AND aa.customer_id=rr.customer_id AND aa.status='RECEIVED' JOIN ll_inventory_position sp ON sp.id=aa.inventory_position_id AND sp.customer_id=aa.customer_id JOIN ll_warehouse_transfer_order ot ON ot.id=aa.transfer_order_id AND ot.customer_id=aa.customer_id JOIN ll_warehouse_transfer_carton chosen ON chosen.transfer_order_id=ot.id AND chosen.customer_id=ot.customer_id JOIN ll_warehouse_transfer_order origin ON origin.customer_id=ot.customer_id AND origin.inbound_code=ot.inbound_code AND origin.operating_warehouse_id=ot.operating_warehouse_id AND origin.transfer_no=origin.inbound_code JOIN ll_warehouse_transfer_carton cc ON cc.transfer_order_id=origin.id AND cc.customer_id=origin.customer_id AND cc.carton_mark=COALESCE(NULLIF(chosen.customer_carton_mark,''),chosen.carton_mark) AND cc.status='SHIPPED' JOIN ll_warehouse_transfer_pallet pp ON pp.id=cc.transfer_pallet_id AND pp.customer_id=cc.customer_id AND pp.warehouse_pallet_id=sp.pallet_id JOIN ll_warehouse_transfer_item ii ON ii.id=cc.transfer_item_id AND ii.customer_id=cc.customer_id WHERE dp.balance_id=ll_inventory_balance.id AND dp.customer_id=ll_inventory_balance.customer_id AND dp.available_packages>0 AND (ll_inventory_balance.sku_code='MIXED' OR ii.sku_code=ll_inventory_balance.sku_code) AND JSON_SEARCH(cc.sku_items_json,'one',CONCAT('%',#{q.skuCode},'%'),NULL,'$[*].skuCode') IS NOT NULL))</if>" +
"<if test='q.productName != null and q.productName != \"\"'> AND (product_name_zh LIKE CONCAT('%',#{q.productName},'%') OR product_name_en LIKE CONCAT('%',#{q.productName},'%'))</if>" + "<if test='q.productName != null and q.productName != \"\"'> AND (product_name_zh LIKE CONCAT('%',#{q.productName},'%') OR product_name_en LIKE CONCAT('%',#{q.productName},'%'))</if>" +
"<if test='q.batchNo != null and q.batchNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id AND p.customer_id=ll_inventory_balance.customer_id AND p.batch_no LIKE CONCAT('%',#{q.batchNo},'%'))</if>" + "<if test='q.batchNo != null and q.batchNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id AND p.customer_id=ll_inventory_balance.customer_id AND p.batch_no LIKE CONCAT('%',#{q.batchNo},'%'))</if>" +
"<if test='q.warehouseLocationCode != null and q.warehouseLocationCode != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id AND p.customer_id=ll_inventory_balance.customer_id AND p.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))</if>" + "<if test='q.warehouseLocationCode != null and q.warehouseLocationCode != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id AND p.customer_id=ll_inventory_balance.customer_id AND p.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))</if>" +
@@ -48,7 +48,7 @@ public interface InventoryQueryMapper {
"<if test='q.inboundOrderNo != null and q.inboundOrderNo != \"\"'> AND b.inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')</if>" + "<if test='q.inboundOrderNo != null and q.inboundOrderNo != \"\"'> AND b.inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')</if>" +
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND b.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" + "<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND b.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=b.id AND l.customer_id=b.customer_id AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))</if>" + "<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=b.id AND l.customer_id=b.customer_id AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))</if>" +
"<if test='q.skuCode != null and q.skuCode != \"\"'> AND b.sku_code LIKE CONCAT('%',#{q.skuCode},'%')</if>" + "<if test='q.skuCode != null and q.skuCode != \"\"'> AND (b.sku_code LIKE CONCAT('%',#{q.skuCode},'%') OR EXISTS (SELECT 1 FROM ll_warehouse_transfer_order tq JOIN ll_warehouse_transfer_carton cq ON cq.transfer_order_id=tq.id AND cq.customer_id=tq.customer_id WHERE tq.customer_id=b.customer_id AND tq.inbound_order_id=b.inbound_order_id AND tq.operating_warehouse_id=b.overseas_warehouse_id AND tq.transfer_no=tq.inbound_code AND cq.status IN ('COUNTED','DISCARD_PENDING') AND EXISTS (SELECT 1 FROM ll_warehouse_transfer_item iq WHERE iq.id=cq.transfer_item_id AND iq.customer_id=cq.customer_id AND (b.sku_code='MIXED' OR iq.sku_code=b.sku_code)) AND JSON_SEARCH(cq.sku_items_json,'one',CONCAT('%',#{q.skuCode},'%'),NULL,'$[*].skuCode') IS NOT NULL) OR EXISTS (SELECT 1 FROM ll_inventory_position dp JOIN ll_warehouse_transfer_receipt rr ON rr.destination_inventory_position_id=dp.id AND rr.customer_id=dp.customer_id JOIN ll_warehouse_transfer_inventory_allocation aa ON aa.id=rr.allocation_id AND aa.customer_id=rr.customer_id AND aa.status='RECEIVED' JOIN ll_inventory_position sp ON sp.id=aa.inventory_position_id AND sp.customer_id=aa.customer_id JOIN ll_warehouse_transfer_order ot ON ot.id=aa.transfer_order_id AND ot.customer_id=aa.customer_id JOIN ll_warehouse_transfer_carton chosen ON chosen.transfer_order_id=ot.id AND chosen.customer_id=ot.customer_id JOIN ll_warehouse_transfer_order origin ON origin.customer_id=ot.customer_id AND origin.inbound_code=ot.inbound_code AND origin.operating_warehouse_id=ot.operating_warehouse_id AND origin.transfer_no=origin.inbound_code JOIN ll_warehouse_transfer_carton cc ON cc.transfer_order_id=origin.id AND cc.customer_id=origin.customer_id AND cc.carton_mark=COALESCE(NULLIF(chosen.customer_carton_mark,''),chosen.carton_mark) AND cc.status='SHIPPED' JOIN ll_warehouse_transfer_pallet pp ON pp.id=cc.transfer_pallet_id AND pp.customer_id=cc.customer_id AND pp.warehouse_pallet_id=sp.pallet_id JOIN ll_warehouse_transfer_item ii ON ii.id=cc.transfer_item_id AND ii.customer_id=cc.customer_id WHERE dp.balance_id=b.id AND dp.customer_id=b.customer_id AND dp.available_packages>0 AND (b.sku_code='MIXED' OR ii.sku_code=b.sku_code) AND JSON_SEARCH(cc.sku_items_json,'one',CONCAT('%',#{q.skuCode},'%'),NULL,'$[*].skuCode') IS NOT NULL))</if>" +
"<if test='q.productName != null and q.productName != \"\"'> AND (b.product_name_zh LIKE CONCAT('%',#{q.productName},'%') OR b.product_name_en LIKE CONCAT('%',#{q.productName},'%'))</if>" + "<if test='q.productName != null and q.productName != \"\"'> AND (b.product_name_zh LIKE CONCAT('%',#{q.productName},'%') OR b.product_name_en LIKE CONCAT('%',#{q.productName},'%'))</if>" +
"<if test='q.batchNo != null and q.batchNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_position p2 WHERE p2.balance_id=b.id AND p2.customer_id=b.customer_id AND p2.batch_no LIKE CONCAT('%',#{q.batchNo},'%'))</if>" + "<if test='q.batchNo != null and q.batchNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_position p2 WHERE p2.balance_id=b.id AND p2.customer_id=b.customer_id AND p2.batch_no LIKE CONCAT('%',#{q.batchNo},'%'))</if>" +
"<if test='q.warehouseLocationCode != null and q.warehouseLocationCode != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_position p2 WHERE p2.balance_id=b.id AND p2.customer_id=b.customer_id AND p2.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))</if>" + "<if test='q.warehouseLocationCode != null and q.warehouseLocationCode != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_position p2 WHERE p2.balance_id=b.id AND p2.customer_id=b.customer_id AND p2.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))</if>" +
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.inventory.query.repository;
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
import com.baiyue.lastleg.inventory.query.mapper.InventoryPieceMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 统一库存混箱事实查询入口。 */
@Repository
public class InventoryPieceRepository {
@Resource private InventoryPieceMapper mapper;
/** 读取相同筛选范围的全部当前可用箱。 */
public List<InventoryPieceMapper.PieceRow> rows(String customerId,InventoryQueryRequests.BalanceQuery query) {
return mapper.rows(customerId,query);
}
}
@@ -0,0 +1,71 @@
package com.baiyue.lastleg.inventory.query.service;
import com.baiyue.lastleg.inventory.query.dto.*;
import com.baiyue.lastleg.inventory.query.mapper.InventoryPieceMapper.PieceRow;
import com.baiyue.lastleg.inventory.query.repository.InventoryPieceRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
/** 用完整箱内清点投影当前可用件数,对缺失或不完整映射显式返回未知。 */
@Service
public class InventoryPieceService {
@Resource private InventoryPieceRepository repository;
@Resource private ObjectMapper objectMapper;
/** 完整筛选的多分组汇总,同一物理托盘按客户及仓去重而非相减。 */
@Transactional(readOnly=true)
public InventoryPieceProjection project(String customerId,InventoryQueryRequests.BalanceQuery query) {
var rows=repository.rows(customerId,query);
var groups=rows.stream().collect(Collectors.groupingBy(r -> Arrays.asList(r.customerId(),r.warehouseId(),r.destinationCode(),r.anchorSkuCode()),LinkedHashMap::new,Collectors.toList()));
var result=new ArrayList<InventoryPieceProjection.Group>(); var pallets=new HashSet<String>();
for(var group:groups.values()) {
result.add(group(group));
for(var row:group) if(row.palletId()!=null) pallets.add(row.customerId()+":"+row.warehouseId()+":"+row.palletId());
}
long known=0,unknown=0,cartons=0;
for(var group:result) { known=Math.addExact(known,group.knownAvailablePieceCount()); unknown+=group.unknownCartonCount(); cartons+=group.availableCartonCount(); }
return new InventoryPieceProjection(unknown==0?known:null,known,unknown,cartons,pallets.size(),result);
}
/** 每个仓位只有精确箱映射完整时才计入已知件数,不能从多余箱中截取或按比例推算。 */
private InventoryPieceProjection.Group group(List<PieceRow> rows) {
var positions=rows.stream().collect(Collectors.groupingBy(PieceRow::positionId,LinkedHashMap::new,Collectors.toList()));
var contents=new TreeMap<String,Long>(); var cartons=new ArrayList<InventoryPieceProjection.Carton>();
var pallets=new HashSet<Long>(); long available=0,unknown=0,known=0;
for(var position:positions.values()) {
var first=position.get(0); available+=first.availablePackages(); if(first.palletId()!=null) pallets.add(first.palletId());
var boxes=position.stream().filter(r -> r.cartonId()!=null).collect(Collectors.toMap(PieceRow::cartonId,r -> r,(a,b)->a,LinkedHashMap::new));
var parsed=new ArrayList<InventoryPieceProjection.Carton>();
for(var row:boxes.values()) parsed.add(carton(row));
if(boxes.size()!=first.availablePackages() || parsed.stream().anyMatch(c -> c.quantity()==null)) { unknown+=first.availablePackages(); continue; }
for(var box:parsed) {
cartons.add(box); known=Math.addExact(known,box.quantity());
for(var sku:box.items()) contents.merge(sku.skuCode(),sku.quantity(),Math::addExact);
}
}
var first=rows.get(0);
return new InventoryPieceProjection.Group(first.customerId(),first.warehouseId(),first.destinationCode(),first.anchorSkuCode(),
unknown==0?known:null,known,unknown,available,pallets.size(),contents.entrySet().stream().map(e -> new InventoryPieceProjection.Sku(e.getKey(),e.getValue())).toList(),cartons);
}
/** 清点JSON与箱总件数必须一致;缺失信息保持未知,不拿首SKU替代混箱。 */
private InventoryPieceProjection.Carton carton(PieceRow row) {
var items=new ArrayList<InventoryPieceProjection.Sku>(); Long quantity=null;
try {
var lines=objectMapper.readTree(row.skuItemsJson()); long sum=0;
if(lines.isArray() && !lines.isEmpty()) {
for(var line:lines) {
String sku=line.path("skuCode").asText(""); var count=line.path("quantity");
if(sku.isBlank() || !count.asText().matches("[0-9]+")) throw new IllegalArgumentException("无效箱内SKU事实");
long pieces=Long.parseLong(count.asText());
if(pieces<=0) throw new IllegalArgumentException("箱内件数必须为正整数");
items.add(new InventoryPieceProjection.Sku(sku,pieces)); sum=Math.addExact(sum,pieces);
}
if(row.countedQuantity()!=null && sum==row.countedQuantity()) quantity=sum;
}
} catch(Exception invalid) { items.clear(); }
return new InventoryPieceProjection.Carton(row.cartonMark(),row.positionId(),row.palletId(),quantity,items);
}
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.palletops.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.palletops.service.PalletOperationRecoveryService;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import jakarta.validation.Valid;
/** 受库存管理权限保护的人工恢复入口,不允许PDA自行批准技术状态关闭。 */
@RestController
@RequestMapping("/api/platform/v1/last-leg/pallet-operation-recoveries")
@PreAuthorize("hasAuthority('lastleg:inventory:manage')")
public class PlatformPalletOperationRecoveryController {
private final PalletOperationRecoveryService service;
/** 注入恢复业务服务。 */
public PlatformPalletOperationRecoveryController(PalletOperationRecoveryService service){this.service=service;}
/** 管理员读取原作业证据。 */
@GetMapping("/{id}") public ApiResponse<PalletOperationRecoveryService.State> inspect(@PathVariable long id){return ApiResponse.success(service.inspect(id));}
/** 以认证管理员身份审计关闭。 */
@PostMapping("/{id}/close-no-effect")
public ApiResponse<PalletOperationRecoveryService.Recovery> close(@PathVariable long id,@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody PalletOperationRecoveryService.Close request){return ApiResponse.success(service.close(id,String.valueOf(user.userId()),request));}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.palletops.mapper;
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
/** 恢复与远端意图使用同一行CAS,恢复关闭后旧执行线程不能继续发起远端写入。 */
@Mapper
public interface PalletOperationRecoveryMapper {
/** 读取原作业技术状态。 */
@Select("SELECT * FROM ll_pda_pallet_operation WHERE id=#{id}")
PdaPalletOperation find(@Param("id") long id);
/** 恢复审批与远端意图按原行串行。 */
@Select("SELECT * FROM ll_pda_pallet_operation WHERE id=#{id} FOR UPDATE")
PdaPalletOperation lock(@Param("id") long id);
/** 恢复关闭后拒绝继续持久化远端意图。 */
@Update("UPDATE ll_pda_pallet_operation SET remote_transitions_json=#{json},updated_at=#{at} WHERE id=#{id} AND status='PROCESSING'")
int journal(@Param("id") long id,@Param("json") String json,@Param("at") LocalDateTime at);
/** 条件关闭原作业并保存恢复审计。 */
@Update("UPDATE ll_pda_pallet_operation SET status='RECOVERED_NO_EFFECT',result_json=#{audit},updated_at=#{at} WHERE id=#{id} AND status IN ('PROCESSING','FAILED_COMPENSATED')")
int close(@Param("id") long id,@Param("audit") String audit,@Param("at") LocalDateTime at);
/** 旧执行线程不能用失败结果覆盖已恢复或已完成终态。 */
@Update("UPDATE ll_pda_pallet_operation SET status=#{status},last_error=#{error},updated_at=#{at} WHERE id=#{id} AND status NOT IN ('RECOVERED_NO_EFFECT','COMPLETED')")
int fail(@Param("id") long id,@Param("status") String status,@Param("error") String error,@Param("at") LocalDateTime at);
}
@@ -53,11 +53,15 @@ public interface PdaPalletOperationMapper extends BaseMapper<PdaPalletOperation>
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
List<MutationPallet> mutationPallets(@Param("warehouseId") String warehouseId, @Param("applicationNos") List<String> applicationNos); List<MutationPallet> mutationPallets(@Param("warehouseId") String warehouseId, @Param("applicationNos") List<String> applicationNos);
/** 收齐原入仓、退回实收、计划及原生退货业务订单,不使用MIN或其他客户代表行。 */ /** 收齐真实尾程订单;入仓快照ID经同客户来源分片解析,缺失关联保留空值以拒绝改绑。 */
@Select("<script>SELECT a.customer_id AS customerId,a.lastleg_order_id AS orderId FROM ll_relabel_application a WHERE a.relabel_no IN " + MUTATION_APPS @Select("<script>SELECT a.customer_id AS customerId,a.lastleg_order_id AS orderId FROM ll_relabel_application a WHERE a.relabel_no IN " + MUTATION_APPS
+ " UNION SELECT i.customer_id,i.source_inbound_order_id FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id " + " UNION SELECT i.customer_id,original.id FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
+ "LEFT JOIN ll_inbound_order inbound ON inbound.id=i.source_inbound_order_id AND inbound.customer_id=i.customer_id "
+ "LEFT JOIN ll_order original ON original.customer_id=inbound.customer_id AND original.source_inbound_id=inbound.source_inbound_id "
+ "WHERE a.relabel_no IN " + MUTATION_APPS + " AND i.return_carton_id IS NULL" + "WHERE a.relabel_no IN " + MUTATION_APPS + " AND i.return_carton_id IS NULL"
+ " UNION SELECT p.customer_id,b.inbound_order_id FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=p.customer_id " + " UNION SELECT p.customer_id,original.id FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=p.customer_id "
+ "LEFT JOIN ll_inbound_order inbound ON inbound.id=b.inbound_order_id AND inbound.customer_id=b.customer_id "
+ "LEFT JOIN ll_order original ON original.customer_id=inbound.customer_id AND original.source_inbound_id=inbound.source_inbound_id "
+ "WHERE p.overseas_warehouse_id=#{warehouseId} AND p.pallet_id IN " + MUTATION_IDS + "WHERE p.overseas_warehouse_id=#{warehouseId} AND p.pallet_id IN " + MUTATION_IDS
+ " UNION SELECT r.customer_id,r.order_id FROM ll_inbound_stock_receipt r JOIN ll_inventory_position p ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id " + " UNION SELECT r.customer_id,r.order_id FROM ll_inbound_stock_receipt r JOIN ll_inventory_position p ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id "
+ "WHERE p.overseas_warehouse_id=#{warehouseId} AND r.warehouse_pallet_id IN " + MUTATION_IDS + "WHERE p.overseas_warehouse_id=#{warehouseId} AND r.warehouse_pallet_id IN " + MUTATION_IDS
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.palletops.repository;
import com.baiyue.lastleg.palletops.mapper.PalletOperationRecoveryMapper;
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
/** 只封装本域作业记录的读取、串行锁和条件写入。 */
@Repository
public class PalletOperationRecoveryRepository {
private final PalletOperationRecoveryMapper mapper;
/** 注入本域作业条件写入映射。 */
public PalletOperationRecoveryRepository(PalletOperationRecoveryMapper mapper){this.mapper=mapper;}
/** 读取原作业。 */
public PdaPalletOperation find(long id){return mapper.find(id);}
/** 为恢复审批锁定原记录。 */
public PdaPalletOperation lock(long id){return mapper.lock(id);}
/** 条件持久化远端意图。 */
public boolean journal(long id,String json,LocalDateTime at){return mapper.journal(id,json,at)==1;}
/** 条件保存恢复终态和审计。 */
public boolean close(long id,String audit,LocalDateTime at){return mapper.close(id,audit,at)==1;}
/** 失败写入同样保护已恢复终态。 */
public boolean fail(long id,String status,String error,LocalDateTime at){return mapper.fail(id,status,error,at)==1;}
}
@@ -0,0 +1,58 @@
package com.baiyue.lastleg.palletops.service;
import com.baiyue.lastleg.palletops.repository.PalletOperationRecoveryRepository;
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import jakarta.validation.constraints.*;
import java.time.LocalDateTime;
/** 只关闭已超时且从未发出远端请求的换标组托,不把未知远端结果推定为无效果。 */
@Service
public class PalletOperationRecoveryService {
private final PalletOperationRecoveryRepository repository;
private final ObjectMapper json;
/** 注入本域作业仓储和统一序列化器。 */
public PalletOperationRecoveryService(PalletOperationRecoveryRepository repository,ObjectMapper json){this.repository=repository;this.json=json;}
public record Close(@Positive long warehouseId,@NotNull LocalDateTime expectedUpdatedAt,
@NotBlank @Size(max=255) String idempotencyKey,@NotBlank @Size(max=1000) String reason){}
public record Recovery(String operationId,String status,String recoveryKey,String operatorId,String reason,
String previousStatus,String previousError,LocalDateTime closedAt,boolean replayed){}
public record State(String operationId,String status,String operatorKey,long warehouseId,String operationType,
String requestHash,String remoteTransitionsJson,String lastError,LocalDateTime updatedAt){}
/** 管理员只读核对原记录和不可变请求哈希,不返回其他账号会话。 */
@Transactional(readOnly=true)
public State inspect(long id){var row=required(repository.find(id));return new State(String.valueOf(id),row.getStatus(),row.getOperatorKey(),row.getOverseasWarehouseId(),row.getOperationType(),row.getRequestHash(),row.getRemoteTransitionsJson(),row.getLastError(),row.getUpdatedAt());}
/** 行锁与远端意图CAS互斥;审计写在原作业结果中,原错误和请求哈希保留。 */
@Transactional
public Recovery close(long id,String operatorId,Close request){
var row=required(repository.lock(id));
if(row.getOverseasWarehouseId()!=request.warehouseId())throw conflict("作业不属于指定仓库");
if("RECOVERED_NO_EFFECT".equals(row.getStatus())){
var existing=read(row.getResultJson());
if(!existing.recoveryKey().equals(request.idempotencyKey())||!existing.reason().equals(request.reason())||!existing.operatorId().equals(operatorId))throw conflict("恢复幂等键或载荷不一致");
return new Recovery(existing.operationId(),existing.status(),existing.recoveryKey(),existing.operatorId(),existing.reason(),existing.previousStatus(),existing.previousError(),existing.closedAt(),true);
}
// 已补偿失败仍须核对没有任何远端意图,不能仅凭补偿状态推断无效果。
if(!"RELABEL_PALLET_DISASSEMBLE".equals(row.getOperationType())||!("PROCESSING".equals(row.getStatus())||"FAILED_COMPENSATED".equals(row.getStatus())))throw conflict("只允许恢复超时的处理中或已补偿失败换标组托");
if(!request.expectedUpdatedAt().equals(row.getUpdatedAt())||row.getUpdatedAt()==null||row.getUpdatedAt().isAfter(LocalDateTime.now().minusMinutes(15)))throw conflict("作业仍活跃或版本已变化,请刷新后核对");
if(row.getResultJson()!=null&&!row.getResultJson().isBlank())throw conflict("作业已有结果,不能关闭为无效果");
try{if(row.getRemoteTransitionsJson()!=null&&!row.getRemoteTransitionsJson().isBlank()){
var attempts=json.readTree(row.getRemoteTransitionsJson());
if(!attempts.isArray()||!attempts.isEmpty())throw conflict("存在远端意图或回执,必须先核实远端效果,不能执行无效果恢复");
}}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw conflict("远端意图记录不可解析,禁止恢复");}
var result=new Recovery(String.valueOf(id),"RECOVERED_NO_EFFECT",request.idempotencyKey(),operatorId,request.reason(),row.getStatus(),row.getLastError(),LocalDateTime.now(),false);
try{if(!repository.close(id,json.writeValueAsString(result),result.closedAt()))throw conflict("作业状态已变化,恢复未写入");}
catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException("恢复审计序列化失败",e);}
return result;
}
/** 重放只读取已持久化的恢复审计。 */
private Recovery read(String value){try{return json.readValue(value,Recovery.class);}catch(Exception e){throw conflict("恢复审计不可读取");}}
/** 原记录不存在时明确拒绝,不新建作业。 */
private PdaPalletOperation required(PdaPalletOperation row){if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"托盘作业不存在");return row;}
/** 统一表达恢复条件或幂等载荷冲突。 */
private ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -54,6 +54,7 @@ import java.util.HexFormat;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
@@ -63,6 +64,7 @@ import java.util.stream.Collectors;
public class PdaPalletOperationService { public class PdaPalletOperationService {
@Resource private PdaPalletOperationMapper mapper; @Resource private PdaPalletOperationMapper mapper;
@Resource private PdaPalletOperationRepository mutationRepository; @Resource private PdaPalletOperationRepository mutationRepository;
@Resource private com.baiyue.lastleg.palletops.repository.PalletOperationRecoveryRepository recoveryRepository;
@Resource private PhysicalPalletSerialPort physicalPalletSerial; @Resource private PhysicalPalletSerialPort physicalPalletSerial;
private Clock mutationClock = Clock.systemUTC(); private Clock mutationClock = Clock.systemUTC();
@Resource private com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper inventoryCommands; @Resource private com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper inventoryCommands;
@@ -257,7 +259,10 @@ public class PdaPalletOperationService {
List<RelabelItem> selected = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<RelabelItem>() List<RelabelItem> selected = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<RelabelItem>()
.eq("application_id", application.getId()).eq("customer_id", application.getCustomerId()) .eq("application_id", application.getId()).eq("customer_id", application.getCustomerId())
.in("carton_mark", marks)); .in("carton_mark", marks));
if (selected.size() != marks.size() // 同一退货实物来源允许多个SKU按件任务;普通箱任务仍必须一箱一项。
boolean bulkSkuSource = selected.stream().allMatch(item -> item.getReturnCartonId() != null
&& "RETURN_BULK_SKU_LABEL".equals(item.getOperationType()));
if ((!bulkSkuSource && selected.size() != marks.size())
|| selected.stream().map(RelabelItem::getCartonMark).distinct().count() != marks.size()) { || selected.stream().map(RelabelItem::getCartonMark).distinct().count() != marks.size()) {
throw conflict("提交的箱唛不属于当前换标单"); throw conflict("提交的箱唛不属于当前换标单");
} }
@@ -417,8 +422,10 @@ public class PdaPalletOperationService {
// 先取得共同串行锁再触碰远端;本地失败补偿也必须在该锁释放前结束。 // 先取得共同串行锁再触碰远端;本地失败补偿也必须在该锁释放前结束。
try { try {
applyPhysical(operation,identity.overseasWarehouseId(),target,"OCCUPIED",target.locationId(),key,"退货换标组托",journal); applyPhysical(operation,identity.overseasWarehouseId(),target,"OCCUPIED",target.locationId(),key,"退货换标组托",journal);
Set<Long> movedCartons = new HashSet<>();
for (int index = 0; index < cartons.size(); index++) { for (int index = 0; index < cartons.size(); index++) {
RelabelItem item = selected.get(index); RelabelItem item = selected.get(index);
if (!movedCartons.add(item.getReturnCartonId())) continue;
ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId()); ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId());
if (carton == null || !Objects.equals(carton.getCustomerId(), application.getCustomerId()) if (carton == null || !Objects.equals(carton.getCustomerId(), application.getCustomerId())
|| (unboundSource ? (org.springframework.util.StringUtils.hasText(carton.getPalletCode()) && !"UNBOUND".equalsIgnoreCase(carton.getPalletCode())) || (unboundSource ? (org.springframework.util.StringUtils.hasText(carton.getPalletCode()) && !"UNBOUND".equalsIgnoreCase(carton.getPalletCode()))
@@ -426,8 +433,18 @@ public class PdaPalletOperationService {
|| !Objects.equals(carton.getRelabelApplicationId(), application.getId()) || !Objects.equals(carton.getRelabelApplicationId(), application.getId())
|| !"RELABEL_PENDING".equals(carton.getStatus())) || !"RELABEL_PENDING".equals(carton.getStatus()))
throw conflict("退货换标来源库存已变化,请刷新后重试"); throw conflict("退货换标来源库存已变化,请刷新后重试");
var sameSource = selected.stream().filter(line -> Objects.equals(line.getReturnCartonId(), carton.getId())).toList();
long move = "RETURN_BULK_SKU_LABEL".equals(item.getOperationType()) long move = "RETURN_BULK_SKU_LABEL".equals(item.getOperationType())
? value(item.getNotifiedPackageCount()) : value(carton.getTotalQuantity()); ? sameSource.stream().mapToLong(line -> value(line.getNotifiedPackageCount())).sum() : value(carton.getTotalQuantity());
if (sameSource.size() > 1) {
// 同一实物的多SKU整批只移动一次;部分混源必须先拆成独立实物,不能逐SKU虚构新箱。
var requestedSkus = new HashMap<String, Long>();
sameSource.forEach(line -> requestedSkus.merge(line.getSkuCode().toUpperCase(Locale.ROOT), (long) value(line.getNotifiedPackageCount()), Long::sum));
var actualSkus = new HashMap<String, Long>();
returnCartonItems.lockCarton(carton.getId()).forEach(line -> actualSkus.merge(line.getSkuCode().toUpperCase(Locale.ROOT), value(line.getQuantity()), Long::sum));
if (move != value(carton.getTotalQuantity()) || !requestedSkus.equals(actualSkus))
throw conflict("多SKU来源须整批完成并核平;部分贴标请先分拣为独立实物");
}
if (move <= 0 || move > value(carton.getTotalQuantity())) throw conflict("来源托盘可拆数量不足"); if (move <= 0 || move > value(carton.getTotalQuantity())) throw conflict("来源托盘可拆数量不足");
if (move < value(carton.getTotalQuantity())) { if (move < value(carton.getTotalQuantity())) {
splitReturnRelabelCarton(application, item, carton, target, move); splitReturnRelabelCarton(application, item, carton, target, move);
@@ -439,7 +456,7 @@ public class PdaPalletOperationService {
returnCartons.updateById(carton); returnCartons.updateById(carton);
} }
} }
recordReturnRelabelBinding(identity, application, selected, target.palletCode()); recordReturnRelabelBinding(identity, application, selected, source == null ? "" : source.palletCode(), target.palletCode());
List<PalletOperationResponses.PalletContent> contents = source == null List<PalletOperationResponses.PalletContent> contents = source == null
? List.of(content(identity.overseasWarehouseId(), physical.resolve(target.palletCode()))) ? List.of(content(identity.overseasWarehouseId(), physical.resolve(target.palletCode())))
: List.of(content(identity.overseasWarehouseId(), physical.resolve(source.palletCode())), : List.of(content(identity.overseasWarehouseId(), physical.resolve(source.palletCode())),
@@ -972,6 +989,7 @@ public class PdaPalletOperationService {
return marks; return marks;
} }
/** 记录经过实物来源校验的逐任务绑定,避免多来源数组失去箱与托盘的精确关系。 */
private void recordRelabelBinding(PdaWarehouseIdentity identity, RelabelMutation mutation, private void recordRelabelBinding(PdaWarehouseIdentity identity, RelabelMutation mutation,
Map<Long, InventoryPosition> targets) { Map<Long, InventoryPosition> targets) {
var app = mutation.application(); var app = mutation.application();
@@ -987,7 +1005,11 @@ public class PdaPalletOperationService {
mutation.itemsByPosition().values().stream().flatMap(List::stream).toList(), mutation.itemsByPosition().values().stream().flatMap(List::stream).toList(),
mutation.itemsByPosition().keySet().stream().map(positions::selectById).filter(Objects::nonNull).findFirst().orElseThrow().getPalletId(), mutation.itemsByPosition().keySet().stream().map(positions::selectById).filter(Objects::nonNull).findFirst().orElseThrow().getPalletId(),
targets.values().iterator().next().getPalletId(), targets.values().iterator().next().getPalletNo(), mutation.measuredCartons()); targets.values().iterator().next().getPalletId(), targets.values().iterator().next().getPalletNo(), mutation.measuredCartons());
record.setDetailJson(write(Map.of("round", Objects.toString(task.getReviewedAt(), "INITIAL"), var itemBindings = mutation.itemsByPosition().entrySet().stream().flatMap(entry -> entry.getValue().stream().map(item ->
Map.of("itemId", String.valueOf(item.getId()), "sourceKnown", true,
"sourcePalletCode", Objects.toString(positions.selectById(entry.getKey()).getPalletNo(), ""),
"targetPalletCode", targets.get(entry.getKey()).getPalletNo()))).toList();
record.setDetailJson(write(Map.of("round", Objects.toString(task.getReviewedAt(), "INITIAL"), "itemBindings", itemBindings,
"measuredCartons", measured, "measuredCartons", measured,
"cartonMarks", mutation.itemsByPosition().values().stream().flatMap(List::stream).map(RelabelItem::getCartonMark).toList(), "cartonMarks", mutation.itemsByPosition().values().stream().flatMap(List::stream).map(RelabelItem::getCartonMark).toList(),
"targetPallets", targets.values().stream().map(InventoryPosition::getPalletNo).distinct().toList(), "targetPallets", targets.values().stream().map(InventoryPosition::getPalletNo).distinct().toList(),
@@ -996,8 +1018,9 @@ public class PdaPalletOperationService {
relabelWorkHistory.insert(record); relabelWorkHistory.insert(record);
} }
/** 来源实托在变更前已经核对,历史只记录该次校验值,不反查变更后的库存托盘。 */
private void recordReturnRelabelBinding(PdaWarehouseIdentity identity, RelabelApplication app, private void recordReturnRelabelBinding(PdaWarehouseIdentity identity, RelabelApplication app,
List<RelabelItem> items, String targetPallet) { List<RelabelItem> items, String sourcePallet, String targetPallet) {
var task = relabelWorkTasks.find(app.getCustomerId(), app.getId()); var task = relabelWorkTasks.find(app.getCustomerId(), app.getId());
if (task == null) throw conflict("退货换标作业任务不存在,不能记录组托"); if (task == null) throw conflict("退货换标作业任务不存在,不能记录组托");
var record = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory(); var record = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory();
@@ -1007,6 +1030,8 @@ public class PdaPalletOperationService {
record.setOperatorKey(identity.operatorKey()); record.setOperatorName(identity.operatorName()); record.setOperatorKey(identity.operatorKey()); record.setOperatorName(identity.operatorName());
record.setCreatedAt(LocalDateTime.now()); record.setCreatedAt(LocalDateTime.now());
record.setDetailJson(write(Map.of("round", Objects.toString(task.getReviewedAt(), "INITIAL"), record.setDetailJson(write(Map.of("round", Objects.toString(task.getReviewedAt(), "INITIAL"),
"itemBindings", items.stream().map(item -> Map.of("itemId", String.valueOf(item.getId()), "sourceKnown", true,
"sourcePalletCode", sourcePallet, "targetPalletCode", targetPallet)).toList(),
"cartonMarks", items.stream().map(RelabelItem::getCartonMark).toList(), "cartonMarks", items.stream().map(RelabelItem::getCartonMark).toList(),
"targetPallets", List.of(targetPallet)))); "targetPallets", List.of(targetPallet))));
relabelWorkHistory.insert(record); relabelWorkHistory.insert(record);
@@ -1200,17 +1225,18 @@ public class PdaPalletOperationService {
newTransaction.executeWithoutResult(status -> { newTransaction.executeWithoutResult(status -> {
PdaPalletOperation operation = mapper.selectById(operationId); PdaPalletOperation operation = mapper.selectById(operationId);
if (operation == null) throw new IllegalStateException("实托作业意图记录不存在,不能发出远端请求"); if (operation == null) throw new IllegalStateException("实托作业意图记录不存在,不能发出远端请求");
operation.setRemoteTransitionsJson(write(attempts)); operation.setUpdatedAt(LocalDateTime.now()); if (!recoveryRepository.journal(operationId, write(attempts), LocalDateTime.now()))
mapper.updateById(operation); throw conflict("作业已由管理员恢复关闭,禁止继续发出远端请求");
}); });
} }
/** 条件记录失败,避免旧线程覆盖管理员已恢复的原记录。 */
private void fail(long operationId, RuntimeException failure, boolean compensated) { private void fail(long operationId, RuntimeException failure, boolean compensated) {
newTransaction.executeWithoutResult(status -> { newTransaction.executeWithoutResult(status -> {
PdaPalletOperation operation = mapper.selectById(operationId); PdaPalletOperation operation = mapper.selectById(operationId);
if (operation == null || "COMPLETED".equals(operation.getStatus())) return; if (operation == null || "COMPLETED".equals(operation.getStatus()) || "RECOVERED_NO_EFFECT".equals(operation.getStatus())) return;
operation.setStatus(compensated ? "FAILED_COMPENSATED" : "COMPENSATION_REQUIRED"); operation.setStatus(compensated ? "FAILED_COMPENSATED" : "COMPENSATION_REQUIRED");
operation.setLastError(limit(failure.getMessage(), 2000)); operation.setUpdatedAt(LocalDateTime.now()); operation.setLastError(limit(failure.getMessage(), 2000)); operation.setUpdatedAt(LocalDateTime.now());
mapper.updateById(operation); recoveryRepository.fail(operationId, operation.getStatus(), operation.getLastError(), operation.getUpdatedAt());
}); });
} }
private boolean compensate(long warehouseId, List<PalletPhysicalGateway.Transition> transitions) { private boolean compensate(long warehouseId, List<PalletPhysicalGateway.Transition> transitions) {
@@ -0,0 +1,52 @@
package com.baiyue.lastleg.query;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
/** 列表与退货全筛选统计共用筛选,避免分页和汇总的客户、状态或时间边界漂移。 */
public final class LastlegOrderFilter {
/** 纯筛选工具不允许实例化。 */
private LastlegOrderFilter() { }
/** 只生成参数绑定的筛选,不读取数据库,不包含分页和排序。 */
public static LambdaQueryWrapper<LastlegOrder> create(String customerId, LastlegOrderRequests.Query query, String relabelNo) {
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo");
}
String currentStatus = StringUtils.hasText(query.currentStatus()) ? query.currentStatus() : query.status();
String warehouse = StringUtils.hasText(query.warehouse()) ? query.warehouse().trim() : null;
return new LambdaQueryWrapper<LastlegOrder>()
.ne(LastlegOrder::getOrderType, "CARGO_DISCARD")
.eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId)
.and(StringUtils.hasText(query.orderNo()), nested -> nested
.like(LastlegOrder::getOrderNo, query.orderNo())
.or()
.like(LastlegOrder::getSourceOrderNo, query.orderNo()))
.and(StringUtils.hasText(relabelNo), nested -> nested
.like(LastlegOrder::getRelabelOrderNo, relabelNo)
.or().apply("EXISTS (SELECT 1 FROM ll_relabel_application r WHERE r.lastleg_order_id=ll_order.id " +
"AND r.customer_id=ll_order.customer_id AND r.relabel_no LIKE CONCAT('%',{0},'%'))", relabelNo))
.eq(StringUtils.hasText(query.orderType()), LastlegOrder::getOrderType, query.orderType())
.apply(StringUtils.hasText(query.businessType()), LastlegOrderMapper.BUSINESS_TYPE_FILTER_SQL, query.businessType())
.apply(StringUtils.hasText(query.businessOrderNo()), LastlegOrderMapper.BUSINESS_ORDER_NO_FILTER_SQL, query.businessOrderNo())
.apply(StringUtils.hasText(query.externalBookingNo()), LastlegOrderMapper.EXTERNAL_BOOKING_NO_SQL + " LIKE CONCAT('%',{0},'%')", query.externalBookingNo())
.apply(query.sailingStage() != null, LastlegOrderMapper.SAILING_STAGE_FILTER_SQL,
query.sailingStage() == LastlegOrderRequests.SailingStage.AFTER_DEPARTURE ? 1 : 0)
.like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode())
.like(StringUtils.hasText(query.lxCode()), LastlegOrder::getLxCode, query.lxCode())
.like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo())
.like(StringUtils.hasText(query.containerNo()), LastlegOrder::getContainerNo, query.containerNo())
.eq(StringUtils.hasText(currentStatus), LastlegOrder::getCurrentStatus, currentStatus)
.apply(StringUtils.hasText(warehouse), LastlegOrderMapper.WAREHOUSE_FILTER_SQL, warehouse)
.eq(StringUtils.hasText(query.transferWarehouseCode()), LastlegOrder::getTransferWarehouseCode,
query.transferWarehouseCode())
.eq(query.abnormal() != null, LastlegOrder::getAbnormal, query.abnormal())
.eq(query.detained() != null, LastlegOrder::getDetained, query.detained())
.ge(query.createdFrom() != null, LastlegOrder::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, LastlegOrder::getCreatedAt, query.createdTo());
}
}
@@ -37,6 +37,16 @@ public class RelabelApplicationController {
} }
@Resource private com.baiyue.lastleg.relabel.pda.service.PdaRelabelService pdaService; @Resource private com.baiyue.lastleg.relabel.pda.service.PdaRelabelService pdaService;
/** 平台代客创建时仍按显式客户和公共作业仓返回正式库存候选。 */
@GetMapping("/inventory-candidates")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_CREATE + "')")
public ApiResponse<java.util.List<RelabelResponses.InventoryCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@RequestParam String operationWarehouseId, @RequestParam(required = false) String inboundCode) {
requirePlatform(user);
return ApiResponse.success(service.candidates(requiredCustomer(customerId), operationWarehouseId, inboundCode));
}
@PostMapping("/{relabelNo}/rework") @PostMapping("/{relabelNo}/rework")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')")
public ApiResponse<com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses.Detail> rework( public ApiResponse<com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses.Detail> rework(
@@ -98,6 +98,7 @@ public class PdaRelabelService {
@Resource private PdaRelabelOperationMapper operations; @Resource private PdaRelabelOperationMapper operations;
@Resource private PdaRelabelHistoryMapper histories; @Resource private PdaRelabelHistoryMapper histories;
@Resource private RelabelApplicationService applicationService; @Resource private RelabelApplicationService applicationService;
@Resource private com.baiyue.lastleg.relabel.repository.RelabelQuantityRepository quantityRepository;
@Resource private RelabelPhotoFileGateway photoFiles; @Resource private RelabelPhotoFileGateway photoFiles;
@Resource private ObjectMapper sourceObjectMapper; @Resource private ObjectMapper sourceObjectMapper;
@Resource private com.baiyue.lastleg.mapper.LastlegOrderMapper tailOrders; @Resource private com.baiyue.lastleg.mapper.LastlegOrderMapper tailOrders;
@@ -143,6 +144,7 @@ public class PdaRelabelService {
return detail(application, tasks.find(application.getCustomerId(), application.getId())); return detail(application, tasks.find(application.getCustomerId(), application.getId()));
} }
/** 认领当前仓作业,并在首次实物移动前保存退货来源的不可变快照。 */
@Transactional @Transactional
public PdaRelabelResponses.Detail claim(String operatorKey, String operatorName, long overseasWarehouseId, public PdaRelabelResponses.Detail claim(String operatorKey, String operatorName, long overseasWarehouseId,
String relabelNo, PdaRelabelRequests.Claim request) { String relabelNo, PdaRelabelRequests.Claim request) {
@@ -191,6 +193,8 @@ public class PdaRelabelService {
} }
insertHistory(task, "CLAIM", null, PROCESSING, operatorKey, operatorName, insertHistory(task, "CLAIM", null, PROCESSING, operatorKey, operatorName,
Map.of("relabelNo", relabelNo)); Map.of("relabelNo", relabelNo));
insertHistory(task, "SOURCE_SNAPSHOT", PROCESSING, PROCESSING, operatorKey, operatorName,
quantityRepository.sourceSnapshot(application));
} }
PdaRelabelResponses.Detail result = detail(application, task); PdaRelabelResponses.Detail result = detail(application, task);
saveOperation(task, operatorKey, "CLAIM", request.idempotencyKey(), command, result); saveOperation(task, operatorKey, "CLAIM", request.idempotencyKey(), command, result);
@@ -444,6 +448,7 @@ public class PdaRelabelService {
return photoFiles.download(application.getCustomerId(), task.getId(), fileId); return photoFiles.download(application.getCustomerId(), task.getId(), fileId);
} }
/** 先核对作业数量,失败不得触发组托或实测的下游校验与写入。 */
@Transactional @Transactional
public PdaRelabelResponses.Detail complete(String operatorKey, String operatorName, long overseasWarehouseId, public PdaRelabelResponses.Detail complete(String operatorKey, String operatorName, long overseasWarehouseId,
String relabelNo, PdaRelabelRequests.Complete request) { String relabelNo, PdaRelabelRequests.Complete request) {
@@ -454,14 +459,14 @@ public class PdaRelabelService {
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true); RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true);
PdaRelabelTask task = requireOwnedTask(application, operatorKey, true); PdaRelabelTask task = requireOwnedTask(application, operatorKey, true);
requireVersion(task, request.expectedVersion()); requireVersion(task, request.expectedVersion());
if (!request.manualCompletion()) bindingGuard.requireBound(application);
palletMeasurements.requireMeasured(application);
List<RelabelItem> detailItems = itemList(application); List<RelabelItem> detailItems = itemList(application);
List<PdaRelabelUnitFact> facts = factList(task); List<PdaRelabelUnitFact> facts = factList(task);
boolean incomplete = detailItems.stream().anyMatch(item -> processedQuantity(facts, item.getId()) < value(item.getNotifiedPackageCount())); boolean incomplete = detailItems.stream().anyMatch(item -> processedQuantity(facts, item.getId()) < value(item.getNotifiedPackageCount()));
if ((incomplete || request.manualCompletion()) && !StringUtils.hasText(request.remark())) if ((incomplete || request.manualCompletion()) && !StringUtils.hasText(request.remark()))
throw conflict("手动完成必须填写仓库异常作业备注"); throw conflict("手动完成必须填写仓库异常作业备注");
if (incomplete && !request.manualCompletion()) throw conflict("仍有未处理箱,请选择手动完成并填写作业备注"); if (incomplete && !request.manualCompletion()) throw conflict("仍有未处理箱,请选择手动完成并填写作业备注");
if (!request.manualCompletion()) bindingGuard.requireBound(application);
palletMeasurements.requireMeasured(application);
if (request.photoFileIds() == null || request.photoFileIds().isEmpty() || request.photoFileIds().size() > 20) throw conflict("完成换标必须上传 1 至 20 张作业照片"); if (request.photoFileIds() == null || request.photoFileIds().isEmpty() || request.photoFileIds().size() > 20) throw conflict("完成换标必须上传 1 至 20 张作业照片");
if (request.manualCompletion() && request.remark().trim().length() > 900) throw conflict("手动作业备注不能超过 900 字"); if (request.manualCompletion() && request.remark().trim().length() > 900) throw conflict("手动作业备注不能超过 900 字");
for (RelabelItem item : detailItems) { for (RelabelItem item : detailItems) {
@@ -16,6 +16,8 @@ import java.util.List;
public class RelabelQuantityRepository { public class RelabelQuantityRepository {
@Resource private RelabelApplicationMapper applications; @Resource private RelabelApplicationMapper applications;
@Resource private RelabelItemMapper items; @Resource private RelabelItemMapper items;
@Resource private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper histories;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper returnCartons;
/** 读取全部筛选结果,不附加当前页限制。 */ /** 读取全部筛选结果,不附加当前页限制。 */
public List<RelabelApplication> filtered(String customerId, RelabelRequests.Query query) { public List<RelabelApplication> filtered(String customerId, RelabelRequests.Query query) {
@@ -28,4 +30,33 @@ public class RelabelQuantityRepository {
.eq(RelabelItem::getApplicationId, application.getId()) .eq(RelabelItem::getApplicationId, application.getId())
.eq(RelabelItem::getCustomerId, application.getCustomerId())); .eq(RelabelItem::getCustomerId, application.getCustomerId()));
} }
/** 只读取同客户同申请的事实历史,按稳定顺序重建首个来源和本轮最终目标。 */
public List<com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory> history(RelabelApplication application) {
return histories.selectList(new LambdaQueryWrapper<com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory>()
.eq(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory::getApplicationId, application.getId())
.eq(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory::getCustomerId, application.getCustomerId())
.orderByAsc(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory::getCreatedAt)
.orderByAsc(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory::getId));
}
/** 认领前按精确退货库存主键校验客户、仓库及占用归属,保存实物来源事实。 */
public java.util.Map<String, Object> sourceSnapshot(RelabelApplication application) {
var bindings = new java.util.ArrayList<java.util.Map<String, Object>>();
for (var item : items(application)) {
if (item.getReturnCartonId() == null) continue;
var carton = returnCartons.selectById(item.getReturnCartonId());
if (carton == null || !java.util.Objects.equals(carton.getCustomerId(), application.getCustomerId())
|| !java.util.Objects.equals(carton.getOverseasWarehouseId(), application.getOverseasWarehouseId())
|| !java.util.Objects.equals(carton.getRelabelApplicationId(), application.getId())
|| !"RELABEL_PENDING".equals(carton.getStatus()) || !"CONFIRMED".equals(carton.getCountStatus())) continue;
var row = new java.util.LinkedHashMap<String, Object>();
row.put("itemId", String.valueOf(item.getId())); row.put("sourceUnitId", String.valueOf(carton.getId()));
row.put("sourceUnitType", java.util.Objects.toString(carton.getSourceUnitType(), "UNKNOWN"));
// 无托盘仅在权威记录明确为空或UNBOUND时计零;不是用箱唛推断托盘。
row.put("sourceKnown", true); row.put("sourcePalletCode", java.util.Objects.toString(carton.getPalletCode(), ""));
bindings.add(row);
}
return java.util.Map.of("schemaVersion", 1, "basis", "CONFIRMED_RETURN_INVENTORY_AT_FIRST_CLAIM", "itemBindings", bindings);
}
} }
@@ -0,0 +1,65 @@
package com.baiyue.lastleg.relabel.service;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.*;
/** 只从不可变事实重建原托及本轮目标,不读取已移动的当前库存位置。 */
public final class RelabelPalletDimensions {
/** 纯事实投影工具不允许实例化。 */
private RelabelPalletDimensions() { }
public record Result(Set<String> sources, Set<String> targets, Long cartons, Map<String, Long> sourceUnits) { }
/** 首次来源跨返工保留;返工后清空目标,随后每个任务仅采用最新绑定。 */
public static Result measure(List<RelabelItem> items, List<PdaRelabelHistory> history, ObjectMapper mapper) {
Map<String, String> sources = new HashMap<>(), targets = new HashMap<>();
Map<String, String> unitIds = new HashMap<>(), unitTypes = new HashMap<>();
Set<String> expected = new HashSet<>();
items.forEach(item -> expected.add(String.valueOf(item.getId())));
for (var event : history) {
if ("REWORK_ARCHIVE".equals(event.getAction())) { targets.clear(); continue; }
if (!Set.of("SOURCE_SNAPSHOT", "PALLET_BIND").contains(event.getAction())) continue;
try {
JsonNode detail = mapper.readTree(event.getDetailJson());
for (var binding : detail.path("itemBindings")) {
String id = binding.path("itemId").asText();
if (!expected.contains(id)) continue;
if (binding.path("sourceKnown").asBoolean(false)) sources.putIfAbsent(id, binding.path("sourcePalletCode").asText(""));
if ("PALLET_BIND".equals(event.getAction()) && binding.hasNonNull("targetPalletCode")) targets.put(id, binding.path("targetPalletCode").asText());
if ("SOURCE_SNAPSHOT".equals(event.getAction()) && binding.hasNonNull("sourceUnitId")) {
unitIds.putIfAbsent(id, binding.path("sourceUnitId").asText());
unitTypes.putIfAbsent(id, binding.path("sourceUnitType").asText());
}
}
// 旧历史只有单一来源和目标时才能确定箱任务关联;多来源数组及散货标识不作猜测。
if (!detail.has("itemBindings") && "PALLET_BIND".equals(event.getAction())) {
var marks = new HashSet<String>(); detail.path("cartonMarks").forEach(mark -> marks.add(mark.asText()));
for (var item : items) {
if ("RETURN_BULK_SKU_LABEL".equals(item.getOperationType()) || !marks.contains(item.getCartonMark())) continue;
String id = String.valueOf(item.getId());
if (detail.path("sourcePallets").size() == 1) sources.putIfAbsent(id, detail.path("sourcePallets").get(0).asText());
if (detail.path("targetPallets").size() == 1) targets.put(id, detail.path("targetPallets").get(0).asText());
}
}
} catch (Exception ignored) { /* 历史缺失或损坏只影响可知维度,不能从当前位置补推。 */ }
}
Long cartons = null;
Map<String, Long> units = null;
if (!items.isEmpty() && unitIds.keySet().containsAll(expected)) {
var grouped = new HashMap<String, Set<String>>();
expected.forEach(id -> grouped.computeIfAbsent(unitTypes.get(id), ignored -> new HashSet<>()).add(unitIds.get(id)));
units = new TreeMap<>();
for (var entry : grouped.entrySet()) units.put(entry.getKey(), (long) entry.getValue().size());
if (grouped.keySet().equals(Set.of("BOX"))) cartons = (long) grouped.get("BOX").size();
}
return new Result(complete(sources, expected), complete(targets, expected), cartons, units);
}
/** 明确无托盘的实物来源可计零;缺少任一任务的快照则整单维度未知。 */
private static Set<String> complete(Map<String, String> values, Set<String> expected) {
if (expected.isEmpty() || !values.keySet().containsAll(expected)) return null;
var result = new HashSet<>(values.values()); result.remove(""); result.remove("UNBOUND"); return result;
}
}
@@ -15,10 +15,12 @@ public class RelabelQuantityService {
@Resource private RelabelQuantityRepository repository; @Resource private RelabelQuantityRepository repository;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
public record Quantity(Long labelPieceCount, Long cartonCount, int skuCount, boolean duplicateCarton, public record Quantity(Long labelPieceCount, Long cartonCount, int skuCount, boolean duplicateCarton,
String quantityUnit, Long sourcePalletCount, Long targetPalletCount) { } String quantityUnit, Long sourcePalletCount, Long targetPalletCount,
Map<String, Long> sourceUnitCounts) { }
public record Totals(long orderCount, long skuCount, long knownLabelPieceCount, long unknownPieceOrders, public record Totals(long orderCount, long skuCount, long knownLabelPieceCount, long unknownPieceOrders,
long knownCartonCount, long unknownCartonOrders, long knownCartonCount, long unknownCartonOrders,
Map<String, Quantity> orders) { } Map<String, Quantity> orders, long knownSourcePalletCount, long unknownSourcePalletOrders,
long knownTargetPalletCount, long unknownTargetPalletOrders) { }
/** 按全部筛选单据聚合;未知件数和箱数保留未知单数,不伪造为零。 */ /** 按全部筛选单据聚合;未知件数和箱数保留未知单数,不伪造为零。 */
@Transactional(readOnly = true) @Transactional(readOnly = true)
@@ -26,20 +28,32 @@ public class RelabelQuantityService {
var dimensions = new LinkedHashMap<String, Quantity>(); var dimensions = new LinkedHashMap<String, Quantity>();
var skus = new HashSet<String>(); var skus = new HashSet<String>();
long pieces = 0, cartons = 0, unknownPieces = 0, unknownCartons = 0; long pieces = 0, cartons = 0, unknownPieces = 0, unknownCartons = 0;
long unknownSources = 0, unknownTargets = 0;
var sourcePallets = new HashSet<String>(); var targetPallets = new HashSet<String>();
for (var application : repository.filtered(customerId, query)) { for (var application : repository.filtered(customerId, query)) {
var detail = repository.items(application); var detail = repository.items(application);
Quantity quantity = measure(detail); Quantity quantity = measure(detail);
var pallets = RelabelPalletDimensions.measure(detail, repository.history(application), objectMapper);
quantity = new Quantity(quantity.labelPieceCount(), quantity.cartonCount() == null ? pallets.cartons() : quantity.cartonCount(),
quantity.skuCount(), quantity.duplicateCarton(), quantity.quantityUnit(),
pallets.sources() == null ? null : (long) pallets.sources().size(),
pallets.targets() == null ? null : (long) pallets.targets().size(), pallets.sourceUnits());
if (pallets.sources() == null) unknownSources++;
else for (var code : pallets.sources()) sourcePallets.add(application.getOverseasWarehouseId() + "|" + code);
if (pallets.targets() == null) unknownTargets++;
else for (var code : pallets.targets()) targetPallets.add(application.getOverseasWarehouseId() + "|" + code);
dimensions.put(application.getRelabelNo(), quantity); dimensions.put(application.getRelabelNo(), quantity);
detail.forEach(item -> collectSkus(item, skus)); detail.forEach(item -> collectSkus(item, skus));
if (quantity.labelPieceCount() == null) unknownPieces++; else pieces = Math.addExact(pieces, quantity.labelPieceCount()); if (quantity.labelPieceCount() == null) unknownPieces++; else pieces = Math.addExact(pieces, quantity.labelPieceCount());
if (quantity.cartonCount() == null) unknownCartons++; else cartons = Math.addExact(cartons, quantity.cartonCount()); if (quantity.cartonCount() == null) unknownCartons++; else cartons = Math.addExact(cartons, quantity.cartonCount());
} }
return new Totals(dimensions.size(), skus.size(), pieces, unknownPieces, cartons, unknownCartons, dimensions); return new Totals(dimensions.size(), skus.size(), pieces, unknownPieces, cartons, unknownCartons, dimensions,
sourcePallets.size(), unknownSources, targetPallets.size(), unknownTargets);
} }
/** 箱级任务按箱唛去重;历史重复关联不重复增加件数并显式告警。 */ /** 箱级任务按箱唛去重;历史重复关联不重复增加件数并显式告警。 */
public Quantity measure(List<RelabelItem> items) { public Quantity measure(List<RelabelItem> items) {
if (items.isEmpty()) return new Quantity(null, null, 0, false, "UNKNOWN", null, null); if (items.isEmpty()) return new Quantity(null, null, 0, false, "UNKNOWN", null, null, null);
long pieces = 0; long pieces = 0;
boolean unknownPieces = false, bulk = false, duplicate = false, missingCarton = false; boolean unknownPieces = false, bulk = false, duplicate = false, missingCarton = false;
var cartons = new HashSet<String>(); var cartons = new HashSet<String>();
@@ -72,7 +86,7 @@ public class RelabelQuantityService {
} }
// 按件退货来源可能是托盘或包裹标识,不能将标识数冒充真实箱数。 // 按件退货来源可能是托盘或包裹标识,不能将标识数冒充真实箱数。
return new Quantity(unknownPieces || duplicate ? null : pieces, bulk || missingCarton ? null : (long) cartons.size(), return new Quantity(unknownPieces || duplicate ? null : pieces, bulk || missingCarton ? null : (long) cartons.size(),
skus.size(), duplicate, bulk ? "PIECE" : "CARTON", null, null); skus.size(), duplicate, bulk ? "PIECE" : "CARTON", null, null, null);
} }
/** SKU种数来自子项;旧快照无子项时才采用旧SKU汇总字段。 */ /** SKU种数来自子项;旧快照无子项时才采用旧SKU汇总字段。 */
@@ -21,6 +21,13 @@ import org.springframework.web.server.ResponseStatusException;
@RequestMapping("/api/merchant/v1/last-leg/return-orders") @RequestMapping("/api/merchant/v1/last-leg/return-orders")
public class MerchantReturnOrderController { public class MerchantReturnOrderController {
@Resource private ReturnReceiptService service; @Resource private ReturnReceiptService service;
@Resource private com.baiyue.lastleg.returnreceipt.service.ReturnOrderStatisticsService statistics;
/** 全筛选权威汇总,客户身份及权限与列表保持一致。 */
@GetMapping("/statistics") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_LIST + "')")
public ApiResponse<com.baiyue.lastleg.returnreceipt.dto.ReturnOrderStatistics> statistics(
@AuthenticationPrincipal AuthenticatedUser user, @Valid ReturnOrderRequests.Query query) {
return ApiResponse.success(statistics.merchant(customer(user),query));
}
@PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE + "')") @PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE + "')")
public ApiResponse<ReturnOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<ReturnOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReturnOrderRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); } @Valid @RequestBody ReturnOrderRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.returnreceipt.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderStatistics;
import com.baiyue.lastleg.returnreceipt.service.ReturnOrderStatisticsService;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.access.prepost.PreAuthorize;
import jakarta.validation.Valid;
/** 退货专属统计沿用尾程订单列表权限与全部筛选条件。 */
@RestController
@RequestMapping("/api/platform/v1/last-leg/return-orders/statistics")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
public class PlatformReturnOrderStatisticsController {
private final ReturnOrderStatisticsService service;
/** 注入只读统计服务。 */
public PlatformReturnOrderStatisticsController(ReturnOrderStatisticsService service) { this.service=service; }
/** 接受与尾程列表相同筛选。 */
@GetMapping
public ApiResponse<ReturnOrderStatistics> statistics(@Valid LastlegOrderRequests.Query query,
@RequestParam(required=false) String relabelNo) { return ApiResponse.success(service.platform(query,relabelNo)); }
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.returnreceipt.dto;
import java.util.List;
/** 件数与签收载体分别统计;未知的预报箱托数不从件数推算。 */
public record ReturnOrderStatistics(Totals totals, List<Dimensions> pageDimensions) {
public record Totals(long orderCount, long skuTypes, long expectedQuantity, long receivedQuantity,
long remainingQuantity, long receivedBoxCount, long receivedParcelCount,
long receivedPalletCount) { }
public record Dimensions(String returnNo, String lastlegOrderId, long expectedQuantity,
long receivedQuantity, long remainingQuantity, long receivedBoxCount,
long receivedParcelCount, long receivedPalletCount) { }
}
@@ -0,0 +1,43 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderStatistics;
import org.apache.ibatis.annotations.*;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import java.util.List;
/** 全筛选先固定订单集合,子项分别聚合,禁止多表连接放大件数。 */
@Mapper
public interface ReturnOrderStatisticsMapper {
String MERCHANT_FILTER = " WHERE ro.customer_id=#{customerId} "
+ "<if test='q.returnNo != null and q.returnNo.trim() != &quot;&quot;'>AND ro.return_no LIKE CONCAT('%',TRIM(#{q.returnNo}),'%')</if>"
+ "<if test='q.status != null and q.status.trim() != &quot;&quot;'>AND ro.status=UPPER(TRIM(#{q.status}))</if>"
+ "<if test='q.skuCode != null and q.skuCode.trim() != &quot;&quot;'>AND EXISTS(SELECT 1 FROM ll_return_order_item i WHERE i.return_order_id=ro.id AND i.customer_id=ro.customer_id AND i.sku_code LIKE CONCAT('%',TRIM(#{q.skuCode}),'%'))</if>"
+ "<if test='q.trackingNo != null and q.trackingNo.trim() != &quot;&quot;'>AND EXISTS(SELECT 1 FROM ll_return_receipt r WHERE r.return_order_id=ro.id AND r.customer_id=ro.customer_id AND r.tracking_no LIKE CONCAT('%',TRIM(#{q.trackingNo}),'%'))</if>";
String MERCHANT_SET = "WITH selected AS (SELECT ro.* FROM ll_return_order ro " + MERCHANT_FILTER + ") ";
String PLATFORM_SET = "WITH selected AS (SELECT ro.* FROM (SELECT ll_order.* FROM ll_order ${ew.customSqlSegment}) p JOIN ll_return_order ro ON ro.lastleg_order_id=p.id AND ro.customer_id=p.customer_id) ";
String PLATFORM_PAGE_SET = "WITH selected AS (SELECT ro.* FROM (SELECT ll_order.* FROM ll_order ${ew.customSqlSegment} ORDER BY ll_order.created_at DESC,ll_order.id DESC LIMIT #{size} OFFSET #{offset}) p JOIN ll_return_order ro ON ro.lastleg_order_id=p.id AND ro.customer_id=p.customer_id) ";
String RECEIPT_SET = ", units AS (SELECT r.* FROM ll_return_receipt r JOIN selected s ON s.id=r.return_order_id AND s.customer_id=r.customer_id), "
+ "carriers AS (SELECT return_order_id,COALESCE(SUM(CASE WHEN unit_type='PARCEL' THEN 0 ELSE total_box_count END),0) AS boxes,"
+ "COUNT(DISTINCT CASE WHEN unit_type='PARCEL' THEN CONCAT(customer_id,':',COALESCE(NULLIF(unit_code,''),receipt_no)) END) AS parcels,"
+ "COUNT(DISTINCT CASE WHEN unit_type='PALLET' THEN CONCAT(customer_id,':',COALESCE(NULLIF(pallet_code,''),unit_code)) END) AS pallets FROM units GROUP BY return_order_id) ";
String TOTALS = "SELECT COUNT(*) AS orderCount,"
+ "(SELECT COUNT(DISTINCT UPPER(TRIM(i.sku_code))) FROM ll_return_order_item i JOIN selected s ON s.id=i.return_order_id AND s.customer_id=i.customer_id) AS skuTypes,"
+ "COALESCE(SUM(expected_quantity),0) AS expectedQuantity,COALESCE(SUM(received_quantity),0) AS receivedQuantity,"
+ "COALESCE(SUM(GREATEST(expected_quantity-received_quantity,0)),0) AS remainingQuantity,"
+ "COALESCE((SELECT SUM(CASE WHEN unit_type='PARCEL' THEN 0 ELSE total_box_count END) FROM units),0) AS receivedBoxCount,"
+ "(SELECT COUNT(DISTINCT CASE WHEN unit_type='PARCEL' THEN CONCAT(customer_id,':',COALESCE(NULLIF(unit_code,''),receipt_no)) END) FROM units) AS receivedParcelCount,"
+ "(SELECT COUNT(DISTINCT CASE WHEN unit_type='PALLET' THEN CONCAT(customer_id,':',COALESCE(NULLIF(pallet_code,''),unit_code)) END) FROM units) AS receivedPalletCount FROM selected";
String DIMENSIONS = "SELECT s.return_no AS returnNo,CAST(s.lastleg_order_id AS CHAR) AS lastlegOrderId,s.expected_quantity AS expectedQuantity,s.received_quantity AS receivedQuantity,"
+ "GREATEST(s.expected_quantity-s.received_quantity,0) AS remainingQuantity,COALESCE(c.boxes,0) AS receivedBoxCount,COALESCE(c.parcels,0) AS receivedParcelCount,COALESCE(c.pallets,0) AS receivedPalletCount "
+ "FROM selected s LEFT JOIN carriers c ON c.return_order_id=s.id ORDER BY s.created_at DESC,s.id DESC LIMIT #{size} OFFSET #{offset}";
@Select("<script>" + MERCHANT_SET + RECEIPT_SET + TOTALS + "</script>")
ReturnOrderStatistics.Totals merchantTotals(@Param("customerId") String customerId, @Param("q") com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests.Query query);
@Select("<script>" + MERCHANT_SET + RECEIPT_SET + DIMENSIONS + "</script>")
List<ReturnOrderStatistics.Dimensions> merchantDimensions(@Param("customerId") String customerId, @Param("q") com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests.Query query, @Param("size") long size, @Param("offset") long offset);
@Select("<script>" + PLATFORM_SET + RECEIPT_SET + TOTALS + "</script>")
ReturnOrderStatistics.Totals platformTotals(@Param("ew") Wrapper<LastlegOrder> wrapper);
@Select("<script>" + PLATFORM_PAGE_SET + RECEIPT_SET + "SELECT s.return_no AS returnNo,CAST(s.lastleg_order_id AS CHAR) AS lastlegOrderId,s.expected_quantity AS expectedQuantity,s.received_quantity AS receivedQuantity,GREATEST(s.expected_quantity-s.received_quantity,0) AS remainingQuantity,COALESCE(c.boxes,0) AS receivedBoxCount,COALESCE(c.parcels,0) AS receivedParcelCount,COALESCE(c.pallets,0) AS receivedPalletCount FROM selected s LEFT JOIN carriers c ON c.return_order_id=s.id</script>")
List<ReturnOrderStatistics.Dimensions> platformDimensions(@Param("ew") Wrapper<LastlegOrder> wrapper, @Param("size") long size, @Param("offset") long offset);
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.returnreceipt.repository;
import com.baiyue.lastleg.returnreceipt.dto.*;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderStatisticsMapper;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.query.LastlegOrderFilter;
import com.baiyue.lastleg.entity.LastlegOrder;
import org.springframework.stereotype.Repository;
/** 列表统计只经本域Mapper读取正式订单与签收事实。 */
@Repository
public class ReturnOrderStatisticsRepository {
private final ReturnOrderStatisticsMapper mapper;
/** 注入订单统计映射。 */
public ReturnOrderStatisticsRepository(ReturnOrderStatisticsMapper mapper) { this.mapper=mapper; }
/** 客户范围来自认证身份,分页不进入总计查询。 */
public ReturnOrderStatistics merchant(String customerId, ReturnOrderRequests.Query query) {
return new ReturnOrderStatistics(mapper.merchantTotals(customerId,query),
mapper.merchantDimensions(customerId,query,query.size(),Math.multiplyExact(query.page()-1,query.size())));
}
/** 与平台尾程列表使用同一筛选构造器,只统计其中原生退货单。 */
public ReturnOrderStatistics platform(LastlegOrderRequests.Query query, String relabelNo) {
var filter=LastlegOrderFilter.create(null,query,relabelNo);
return new ReturnOrderStatistics(mapper.platformTotals(filter),
mapper.platformDimensions(filter,query.size(),Math.multiplyExact(query.page()-1,query.size())));
}
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.returnreceipt.service;
import com.baiyue.lastleg.returnreceipt.dto.*;
import com.baiyue.lastleg.returnreceipt.repository.ReturnOrderStatisticsRepository;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** 汇总和当前页维度在同一只读快照内返回,避免签收并发时前后口径不一致。 */
@Service
public class ReturnOrderStatisticsService {
private final ReturnOrderStatisticsRepository repository;
/** 注入只读统计仓储。 */
public ReturnOrderStatisticsService(ReturnOrderStatisticsRepository repository) { this.repository=repository; }
/** 当前客户的总计与页内维度使用同一只读快照。 */
@Transactional(readOnly=true, isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public ReturnOrderStatistics merchant(String customerId, ReturnOrderRequests.Query query) { return repository.merchant(customerId,query); }
/** 平台筛选范围中的退货总计与混合列表页内维度。 */
@Transactional(readOnly=true, isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public ReturnOrderStatistics platform(LastlegOrderRequests.Query query, String relabelNo) { return repository.platform(query,relabelNo); }
}
@@ -86,6 +86,7 @@ public class LastlegOrderService {
@Resource private WarehouseResourceGateway warehouseResourceGateway; @Resource private WarehouseResourceGateway warehouseResourceGateway;
@Resource private ContainerShippingGateMapper containerShippingGates; @Resource private ContainerShippingGateMapper containerShippingGates;
@Resource private ReturnOrderReferenceResolver returnOrderReferences; @Resource private ReturnOrderReferenceResolver returnOrderReferences;
@Resource private com.baiyue.lastleg.transfer.service.TransferQuantityService transferQuantities;
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) { public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
return page(customerId, query, null); return page(customerId, query, null);
@@ -102,11 +103,12 @@ public class LastlegOrderService {
Map<Long, LastlegOrderMapper.OrderWeightRow> weights = orderWeights(page.getRecords()); Map<Long, LastlegOrderMapper.OrderWeightRow> weights = orderWeights(page.getRecords());
Map<Long, List<com.baiyue.lastleg.dto.RelabelSubOrder>> relabels = relabelSubOrders(page.getRecords()); Map<Long, List<com.baiyue.lastleg.dto.RelabelSubOrder>> relabels = relabelSubOrders(page.getRecords());
Map<Long, LastlegOrderSummary.BusinessSource> businessSources = businessSources(page.getRecords()); Map<Long, LastlegOrderSummary.BusinessSource> businessSources = businessSources(page.getRecords());
var quantities = transferQuantities.forOrders(page.getRecords());
return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()), return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()),
StringUtils.hasText(order.getSourceContainerBookingId()) StringUtils.hasText(order.getSourceContainerBookingId())
? containerNodes.get(order.getSourceContainerBookingId()) : null, ? containerNodes.get(order.getSourceContainerBookingId()) : null,
dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"), weights.get(order.getId()), dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"), weights.get(order.getId()),
relabels.getOrDefault(order.getId(), List.of()), businessSources.get(order.getId()))).toList(), relabels.getOrDefault(order.getId(), List.of()), businessSources.get(order.getId()), quantities.get(order.getId()))).toList(),
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
} }
@@ -363,7 +365,7 @@ public class LastlegOrderService {
return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId()), return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId()),
containerNode(order), dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED"), containerNode(order), dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED"),
orderWeights(List.of(order)).get(order.getId()), relabelSubOrders(List.of(order)).getOrDefault(order.getId(), List.of()), orderWeights(List.of(order)).get(order.getId()), relabelSubOrders(List.of(order)).getOrDefault(order.getId(), List.of()),
businessSources(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream() businessSources(List.of(order)).get(order.getId()), transferQuantities.forOrders(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream()
.map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(), .map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(),
value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream() value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream()
.map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(), .map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(),
@@ -532,45 +534,18 @@ public class LastlegOrderService {
return orders.size(); return orders.size();
} }
/** 与主列表复用同一筛选,汇总只纳入仓储转运且不使用分页参数。 */
public com.baiyue.lastleg.transfer.dto.TransferQuantityTotals transferQuantityTotals(
String customerId, LastlegOrderRequests.Query query, String relabelNo) {
return transferQuantities.totals(filter(customerId, query, relabelNo).eq(LastlegOrder::getOrderType,"WAREHOUSE_TRANSFER"));
}
private LambdaQueryWrapper<LastlegOrder> filter(String customerId, LastlegOrderRequests.Query query) { private LambdaQueryWrapper<LastlegOrder> filter(String customerId, LastlegOrderRequests.Query query) {
return filter(customerId, query, null); return filter(customerId, query, null);
} }
private LambdaQueryWrapper<LastlegOrder> filter(String customerId, LastlegOrderRequests.Query query, String relabelNo) { private LambdaQueryWrapper<LastlegOrder> filter(String customerId, LastlegOrderRequests.Query query, String relabelNo) {
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { return com.baiyue.lastleg.query.LastlegOrderFilter.create(customerId, query, relabelNo);
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo");
}
String currentStatus = StringUtils.hasText(query.currentStatus()) ? query.currentStatus() : query.status();
String warehouse = StringUtils.hasText(query.warehouse()) ? query.warehouse().trim() : null;
return new LambdaQueryWrapper<LastlegOrder>()
.ne(LastlegOrder::getOrderType, "CARGO_DISCARD")
.eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId)
.and(StringUtils.hasText(query.orderNo()), nested -> nested
.like(LastlegOrder::getOrderNo, query.orderNo())
.or()
.like(LastlegOrder::getSourceOrderNo, query.orderNo()))
.and(StringUtils.hasText(relabelNo), nested -> nested
.like(LastlegOrder::getRelabelOrderNo, relabelNo)
.or().apply("EXISTS (SELECT 1 FROM ll_relabel_application r WHERE r.lastleg_order_id=ll_order.id " +
"AND r.customer_id=ll_order.customer_id AND r.relabel_no LIKE CONCAT('%',{0},'%'))", relabelNo))
.eq(StringUtils.hasText(query.orderType()), LastlegOrder::getOrderType, query.orderType())
.apply(StringUtils.hasText(query.businessType()), LastlegOrderMapper.BUSINESS_TYPE_FILTER_SQL, query.businessType())
.apply(StringUtils.hasText(query.businessOrderNo()), LastlegOrderMapper.BUSINESS_ORDER_NO_FILTER_SQL, query.businessOrderNo())
.apply(StringUtils.hasText(query.externalBookingNo()), LastlegOrderMapper.EXTERNAL_BOOKING_NO_SQL + " LIKE CONCAT('%',{0},'%')", query.externalBookingNo())
.apply(query.sailingStage() != null, LastlegOrderMapper.SAILING_STAGE_FILTER_SQL,
query.sailingStage() == LastlegOrderRequests.SailingStage.AFTER_DEPARTURE ? 1 : 0)
.like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode())
.like(StringUtils.hasText(query.lxCode()), LastlegOrder::getLxCode, query.lxCode())
.like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo())
.like(StringUtils.hasText(query.containerNo()), LastlegOrder::getContainerNo, query.containerNo())
.eq(StringUtils.hasText(currentStatus), LastlegOrder::getCurrentStatus, currentStatus)
.apply(StringUtils.hasText(warehouse), LastlegOrderMapper.WAREHOUSE_FILTER_SQL, warehouse)
.eq(StringUtils.hasText(query.transferWarehouseCode()), LastlegOrder::getTransferWarehouseCode,
query.transferWarehouseCode())
.eq(query.abnormal() != null, LastlegOrder::getAbnormal, query.abnormal())
.eq(query.detained() != null, LastlegOrder::getDetained, query.detained())
.ge(query.createdFrom() != null, LastlegOrder::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, LastlegOrder::getCreatedAt, query.createdTo());
} }
private LastlegOrder findOwned(String customerId, long orderId) { private LastlegOrder findOwned(String customerId, long orderId) {
@@ -647,7 +622,8 @@ public class LastlegOrderService {
String containerNode, String dispatchStatus, String containerNode, String dispatchStatus,
LastlegOrderMapper.OrderWeightRow weight, LastlegOrderMapper.OrderWeightRow weight,
List<com.baiyue.lastleg.dto.RelabelSubOrder> relabelApplications, List<com.baiyue.lastleg.dto.RelabelSubOrder> relabelApplications,
LastlegOrderSummary.BusinessSource businessSource) { LastlegOrderSummary.BusinessSource businessSource,
com.baiyue.lastleg.transfer.dto.TransferQuantityView transferQuantity) {
List<LastlegOrderMapper.PalletMergeRelationRow> related = relations == null ? List.of() : relations; List<LastlegOrderMapper.PalletMergeRelationRow> related = relations == null ? List.of() : relations;
return new LastlegOrderSummary(o.getId(), displayOrderNo(o), o.getOrderType(), return new LastlegOrderSummary(o.getId(), displayOrderNo(o), o.getOrderType(),
LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(), LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(),
@@ -665,7 +641,7 @@ public class LastlegOrderService {
o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null), o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null),
related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedInboundNo).filter(Objects::nonNull).distinct().toList(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedInboundNo).filter(Objects::nonNull).distinct().toList(),
o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(), o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(),
o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt(), relabelApplications, businessSource); o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt(), relabelApplications, businessSource, transferQuantity);
} }
private static Integer packageDifference(LastlegOrder order) { private static Integer packageDifference(LastlegOrder order) {
@@ -722,3 +698,4 @@ public class LastlegOrderService {
} }
} }
@@ -77,6 +77,14 @@ public class MerchantWarehouseTransferController {
return ApiResponse.success(service.submit(customer(user), transferNo, operator(user))); return ApiResponse.success(service.submit(customer(user), transferNo, operator(user)));
} }
/** 客户当前筛选的全部转运数量,不受页码影响。 */
@GetMapping("/quantities")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_LIST + "')")
public ApiResponse<com.baiyue.lastleg.transfer.dto.TransferQuantityTotals> quantities(
@AuthenticationPrincipal AuthenticatedUser user,@Valid WarehouseTransferRequests.Query query) {
return ApiResponse.success(service.quantityTotals(customer(user),query));
}
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_LIST + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_LIST + "')")
public ApiResponse<PageResult<WarehouseTransferResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<PageResult<WarehouseTransferResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@@ -0,0 +1,8 @@
package com.baiyue.lastleg.transfer.dto;
/** 全筛选结果汇总,不受分页影响;已知小计与未知票数同时返回。 */
public record TransferQuantityTotals(long orderCount, Long declaredPieceCount, long knownDeclaredPieceCount,
long unknownDeclaredOrderCount, Long receivedPieceCount, long knownReceivedPieceCount,
long unknownReceivedOrderCount, long declaredCartonCount, long receivedCartonCount,
long receivedPalletCount, long availableCartonCount, long shippedCartonCount,
long remainingPalletCount, long shippedPalletCount) { }
@@ -0,0 +1,6 @@
package com.baiyue.lastleg.transfer.dto;
/** 分离客户预报、历史实收和当前箱状态;未知件数保持空值。 */
public record TransferQuantityView(Long lastlegOrderId, Long declaredPieceCount, Long receivedPieceCount,
long declaredCartonCount, long receivedCartonCount, long receivedPalletCount,
long availableCartonCount, long shippedCartonCount, long remainingPalletCount, long shippedPalletCount) { }
@@ -212,7 +212,10 @@ public final class WarehouseTransferRequests {
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
String transferNo, String inboundCode, String operatingWarehouseId, String transferNo, String inboundCode, String operatingWarehouseId,
String fbaWarehouseCode, String status, Boolean needRelabel, String fbaWarehouseCode, String status, Boolean needRelabel,
LocalDateTime createdFrom, LocalDateTime createdTo) { LocalDateTime createdFrom, LocalDateTime createdTo,
LocalDateTime reviewedFrom, LocalDateTime reviewedTo,
LocalDateTime measuredFrom, LocalDateTime measuredTo,
LocalDateTime outboundFrom, LocalDateTime outboundTo) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; } public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
} }
} }
@@ -0,0 +1,78 @@
package com.baiyue.lastleg.transfer.mapper;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.transfer.dto.TransferQuantityView;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 使用主订单相同筛选读取转运数量事实,不把历史实收与剩余库存相减。 */
@Mapper
public interface TransferQuantityMapper {
String SELECTED = "SELECT id,customer_id FROM ll_order ${ew.customSqlSegment}";
String RECEIVED = "c.counted_at IS NOT NULL AND c.status IN ('COUNTED','DISCARD_PENDING','DISCARDED','SHIPPED')";
String FACTS = """
SELECT t.lastleg_order_id lastlegOrderId,
(SELECT CASE WHEN COUNT(*)=0 OR COUNT(*)&lt;&gt;COUNT(i.declared_quantity) THEN NULL ELSE SUM(i.declared_quantity) END
FROM ll_warehouse_transfer_item i WHERE i.customer_id=t.customer_id AND i.transfer_order_id=t.id) declaredPieceCount,
(SELECT CASE WHEN COUNT(*)=0 OR COUNT(*)&lt;&gt;COUNT(c.counted_quantity) THEN NULL ELSE SUM(c.counted_quantity) END
FROM ll_warehouse_transfer_carton c WHERE c.customer_id=t.customer_id AND c.transfer_order_id=t.id AND
""" + RECEIVED + """
) receivedPieceCount,
t.package_count declaredCartonCount,
COALESCE(f.receivedCartonCount,0) receivedCartonCount, COALESCE(f.receivedPalletCount,0) receivedPalletCount,
COALESCE(f.availableCartonCount,0) availableCartonCount, COALESCE(f.shippedCartonCount,0) shippedCartonCount,
COALESCE(f.remainingPalletCount,0) remainingPalletCount, COALESCE(f.shippedPalletCount,0) shippedPalletCount
FROM ll_warehouse_transfer_order t JOIN (
""" + SELECTED + """
) selected ON selected.id=t.lastleg_order_id AND selected.customer_id=t.customer_id
LEFT JOIN (
SELECT c.customer_id,c.transfer_order_id,
""" + "SUM(" + RECEIVED + ") receivedCartonCount," +
"COUNT(DISTINCT CASE WHEN " + RECEIVED + " THEN p.warehouse_pallet_id END) receivedPalletCount," + """
SUM(c.status='COUNTED') availableCartonCount, SUM(c.status='SHIPPED') shippedCartonCount,
COUNT(DISTINCT CASE WHEN c.status IN ('COUNTED','DISCARD_PENDING') THEN p.warehouse_pallet_id END) remainingPalletCount,
COUNT(DISTINCT CASE WHEN c.status='SHIPPED' THEN p.warehouse_pallet_id END) shippedPalletCount
FROM ll_warehouse_transfer_carton c LEFT JOIN ll_warehouse_transfer_pallet p
ON p.id=c.transfer_pallet_id AND p.customer_id=c.customer_id
GROUP BY c.customer_id,c.transfer_order_id
) f ON f.transfer_order_id=t.id AND f.customer_id=t.customer_id
""";
/** 一次读取筛选范围或本页订单的预报及实收事实。 */
@Select("<script>" + FACTS + "</script>")
List<TransferQuantityView> rows(@Param("ew") Wrapper<LastlegOrder> filter);
/** 按真实托盘身份在完整筛选集合去重,同托部分出库可同时属于实收、剩余、出库集合。 */
@Select("<script>SELECT DISTINCT c.customer_id customerId,p.warehouse_pallet_id palletId,c.status status " +
"FROM ll_warehouse_transfer_order t JOIN (" + SELECTED + ") selected " +
"ON selected.id=t.lastleg_order_id AND selected.customer_id=t.customer_id " +
"JOIN ll_warehouse_transfer_carton c ON c.transfer_order_id=t.id AND c.customer_id=t.customer_id " +
"JOIN ll_warehouse_transfer_pallet p ON p.id=c.transfer_pallet_id AND p.customer_id=c.customer_id " +
"WHERE " + RECEIVED + " AND p.warehouse_pallet_id IS NOT NULL</script>")
List<PalletFact> pallets(@Param("ew") Wrapper<LastlegOrder> filter);
/** 物理托盘事实,保留客户隔离。 */
record PalletFact(String customerId, Long palletId, String status) { }
/** 库存转运目的仓实收依正式签收凭据;件数只取精确选箱对应原清点事实。 */
@Select("<script>SELECT t.lastleg_order_id lastlegOrderId,t.customer_id customerId,a.package_count cartonCount," +
"a.pallet_id sourcePalletId,a.status status,r.id receiptId,dp.pallet_id destinationPalletId," +
"(SELECT CASE WHEN COUNT(DISTINCT c.id)=a.package_count AND COUNT(*)=COUNT(c.counted_quantity) THEN SUM(c.counted_quantity) ELSE NULL END " +
"FROM ll_warehouse_transfer_carton chosen JOIN ll_warehouse_transfer_order origin " +
"ON origin.customer_id=t.customer_id AND origin.inbound_code=t.inbound_code AND origin.operating_warehouse_id=t.operating_warehouse_id AND origin.transfer_no=origin.inbound_code " +
"JOIN ll_warehouse_transfer_carton c ON c.customer_id=origin.customer_id AND c.transfer_order_id=origin.id " +
"AND c.carton_mark=COALESCE(NULLIF(chosen.customer_carton_mark,''),chosen.carton_mark) " +
"JOIN ll_warehouse_transfer_pallet cp ON cp.id=c.transfer_pallet_id AND cp.customer_id=c.customer_id AND cp.warehouse_pallet_id=a.pallet_id " +
"WHERE chosen.transfer_order_id=t.id AND chosen.customer_id=t.customer_id AND c.counted_at IS NOT NULL) pieceCount " +
"FROM ll_warehouse_transfer_order t JOIN (" + SELECTED + ") selected ON selected.id=t.lastleg_order_id AND selected.customer_id=t.customer_id " +
"JOIN ll_warehouse_transfer_inventory_allocation a ON a.transfer_order_id=t.id AND a.customer_id=t.customer_id " +
"LEFT JOIN ll_warehouse_transfer_receipt r ON r.allocation_id=a.id AND r.customer_id=a.customer_id " +
"LEFT JOIN ll_inventory_position dp ON dp.id=r.destination_inventory_position_id AND dp.customer_id=r.customer_id " +
"WHERE t.transfer_no&lt;&gt;t.inbound_code AND a.status IN ('RESERVED','SHIPPED','RECEIVED')</script>")
List<MovementFact> movements(@Param("ew") Wrapper<LastlegOrder> filter);
/** 每个分配来源的实际出库与目的仓签收事实。 */
record MovementFact(Long lastlegOrderId,String customerId,long cartonCount,Long sourcePalletId,String status,
Long receiptId,Long destinationPalletId,Long pieceCount) { }
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.transfer.repository;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.transfer.dto.TransferQuantityView;
import com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 转运数量只读持久化入口。 */
@Repository
public class TransferQuantityRepository {
@Resource private TransferQuantityMapper mapper;
@Resource private com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper transfers;
/** 以客户列表相同筛选读取关联主订单,不读取分页参数。 */
public List<Long> selectedOrderIds(Wrapper<com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder> filter) {
return transfers.selectList(filter).stream()
.map(com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder::getLastlegOrderId)
.filter(java.util.Objects::nonNull).distinct().toList();
}
/** 读取完整筛选集合的事实,分页由主列表单独处理。 */
public List<TransferQuantityView> rows(Wrapper<LastlegOrder> filter) { return mapper.rows(filter); }
/** 读取完整筛选集合的物理托盘。 */
public List<TransferQuantityMapper.PalletFact> pallets(Wrapper<LastlegOrder> filter) { return mapper.pallets(filter); }
/** 读取库存转运实际出库及目的仓签收,按来源分配去重。 */
public List<TransferQuantityMapper.MovementFact> movements(Wrapper<LastlegOrder> filter) { return mapper.movements(filter); }
}
@@ -0,0 +1,87 @@
package com.baiyue.lastleg.transfer.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.transfer.dto.*;
import com.baiyue.lastleg.transfer.repository.TransferQuantityRepository;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.function.ToLongFunction;
import java.util.stream.Collectors;
/** 预报、实收、当前剩余分别汇总,空件数不会被零值掩盖。 */
@Service
public class TransferQuantityService {
@Resource private TransferQuantityRepository repository;
/** 客户列表共享完整筛选,主订单查询仍显式限定客户。 */
@Transactional(readOnly=true)
public TransferQuantityTotals merchantTotals(Wrapper<com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder> filter, String customerId) {
var ids = repository.selectedOrderIds(filter);
var orders = new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId, customerId);
if (ids.isEmpty()) orders.apply("1=0"); else orders.in(LastlegOrder::getId, ids);
return totals(orders);
}
/** 主列表批量加载本页转运,其他业务不改变字段语义。 */
public Map<Long,TransferQuantityView> forOrders(List<LastlegOrder> orders) {
var ids=orders.stream().filter(o -> "WAREHOUSE_TRANSFER".equals(o.getOrderType())).map(LastlegOrder::getId).toList();
if(ids.isEmpty()) return Map.of();
var filter = new LambdaQueryWrapper<LastlegOrder>().in(LastlegOrder::getId,ids);
return withMovements(repository.rows(filter),repository.movements(filter))
.stream().collect(Collectors.toMap(TransferQuantityView::lastlegOrderId, v -> v));
}
/** 同一只读事务按完整筛选汇总,page/size不参与数量计算。 */
@Transactional(readOnly=true)
public TransferQuantityTotals totals(Wrapper<LastlegOrder> filter) {
var movements=repository.movements(filter);
var rows=withMovements(repository.rows(filter),movements); var pallets=repository.pallets(filter);
long unknownDeclared=rows.stream().filter(v -> v.declaredPieceCount()==null).count();
long unknownReceived=rows.stream().filter(v -> v.receivedPieceCount()==null).count();
long declared=sum(rows,v -> v.declaredPieceCount()==null?0:v.declaredPieceCount());
long received=sum(rows,v -> v.receivedPieceCount()==null?0:v.receivedPieceCount());
var all=new HashSet<String>(); var remaining=new HashSet<String>(); var shipped=new HashSet<String>();
for(var pallet:pallets) {
String key=pallet.customerId()+":"+pallet.palletId(); all.add(key);
if(Set.of("COUNTED","DISCARD_PENDING").contains(pallet.status())) remaining.add(key);
if("SHIPPED".equals(pallet.status())) shipped.add(key);
}
for (var move:movements) {
if (move.receiptId()!=null && move.destinationPalletId()!=null) all.add(move.customerId()+":"+move.destinationPalletId());
if (Set.of("SHIPPED","RECEIVED").contains(move.status()) && move.sourcePalletId()!=null) shipped.add(move.customerId()+":"+move.sourcePalletId());
}
return new TransferQuantityTotals(rows.size(),unknownDeclared==0?declared:null,declared,unknownDeclared,
unknownReceived==0?received:null,received,unknownReceived,sum(rows,TransferQuantityView::declaredCartonCount),
sum(rows,TransferQuantityView::receivedCartonCount),all.size(),sum(rows,TransferQuantityView::availableCartonCount),
sum(rows,TransferQuantityView::shippedCartonCount),remaining.size(),shipped.size());
}
/** 库存转运的实收来自目的仓凭据,原仓出库事实保留独立维度。 */
private List<TransferQuantityView> withMovements(List<TransferQuantityView> rows,
List<com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper.MovementFact> movements) {
var grouped=movements.stream().collect(Collectors.groupingBy(com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper.MovementFact::lastlegOrderId));
return rows.stream().map(row -> {
var facts=grouped.get(row.lastlegOrderId()); if(facts==null) return row;
long received=0,pieces=0,shipped=0; boolean known=true; var targets=new HashSet<Long>(); var sources=new HashSet<Long>();
for(var fact:facts) {
if(fact.receiptId()!=null) {
received=Math.addExact(received,fact.cartonCount());
if(fact.pieceCount()==null) known=false; else pieces=Math.addExact(pieces,fact.pieceCount());
if(fact.destinationPalletId()!=null) targets.add(fact.destinationPalletId());
}
if(Set.of("SHIPPED","RECEIVED").contains(fact.status())) { shipped=Math.addExact(shipped,fact.cartonCount()); if(fact.sourcePalletId()!=null) sources.add(fact.sourcePalletId()); }
}
return new TransferQuantityView(row.lastlegOrderId(),row.declaredPieceCount(),received>0&&known?pieces:null,
row.declaredCartonCount(),received,targets.size(),0,shipped,0,sources.size());
}).toList();
}
/** 使用精确整数累加,溢出不能静默返回错误汇总。 */
private long sum(List<TransferQuantityView> rows,ToLongFunction<TransferQuantityView> measure) {
long value=0; for(var row:rows) value=Math.addExact(value,measure.applyAsLong(row)); return value;
}
}
@@ -92,6 +92,7 @@ import java.util.stream.Collectors;
@Service @Service
public class WarehouseTransferService { public class WarehouseTransferService {
@Resource private TransferQuantityService transferQuantityService;
@Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupService pickupService; @Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupService pickupService;
/** 退货单在转运来源中的展示编码,避免把退货单号误当成普通入仓码查询。 */ /** 退货单在转运来源中的展示编码,避免把退货单号误当成普通入仓码查询。 */
public static final String RETURN_ORDER_PREFIX = "RETURN_ORDER:"; public static final String RETURN_ORDER_PREFIX = "RETURN_ORDER:";
@@ -551,19 +552,15 @@ public class WarehouseTransferService {
transferCartons.updateById(carton); transferCartons.updateById(carton);
} }
} }
@Transactional(readOnly = true) /** 列表和全筛选数量共享条件,避免翻页或状态分组后口径漂移。 */
public PageResult<WarehouseTransferResponses.Summary> page(String customerId, WarehouseTransferRequests.Query query) { private LambdaQueryWrapper<WarehouseTransferOrder> transferPageFilter(String customerId,
return page(customerId, query, true); WarehouseTransferRequests.Query query, boolean includeDrafts) {
} validateQuantityRange(query.createdFrom(), query.createdTo(), "创建");
validateQuantityRange(query.reviewedFrom(), query.reviewedTo(), "审核");
@Transactional(readOnly = true) validateQuantityRange(query.measuredFrom(), query.measuredTo(), "清点完成");
public PageResult<WarehouseTransferResponses.Summary> page(String customerId, WarehouseTransferRequests.Query query, validateQuantityRange(query.outboundFrom(), query.outboundTo(), "出库");
boolean includeDrafts) {
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) {
throw badRequest("createdFrom must not be after createdTo");
}
List<String> groupedStatuses = customerMainStatuses(query.status()); List<String> groupedStatuses = customerMainStatuses(query.status());
LambdaQueryWrapper<WarehouseTransferOrder> wrapper = new LambdaQueryWrapper<WarehouseTransferOrder>() return new LambdaQueryWrapper<WarehouseTransferOrder>()
.eq(StringUtils.hasText(customerId), WarehouseTransferOrder::getCustomerId, customerId) .eq(StringUtils.hasText(customerId), WarehouseTransferOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.transferNo()), WarehouseTransferOrder::getTransferNo, query.transferNo()) .like(StringUtils.hasText(query.transferNo()), WarehouseTransferOrder::getTransferNo, query.transferNo())
.like(StringUtils.hasText(query.inboundCode()), WarehouseTransferOrder::getInboundCode, query.inboundCode()) .like(StringUtils.hasText(query.inboundCode()), WarehouseTransferOrder::getInboundCode, query.inboundCode())
@@ -577,7 +574,35 @@ public class WarehouseTransferService {
.eq(query.needRelabel() != null, WarehouseTransferOrder::getNeedRelabel, query.needRelabel()) .eq(query.needRelabel() != null, WarehouseTransferOrder::getNeedRelabel, query.needRelabel())
.ge(query.createdFrom() != null, WarehouseTransferOrder::getCreatedAt, query.createdFrom()) .ge(query.createdFrom() != null, WarehouseTransferOrder::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, WarehouseTransferOrder::getCreatedAt, query.createdTo()) .le(query.createdTo() != null, WarehouseTransferOrder::getCreatedAt, query.createdTo())
.ge(query.reviewedFrom() != null, WarehouseTransferOrder::getReviewedAt, query.reviewedFrom())
.le(query.reviewedTo() != null, WarehouseTransferOrder::getReviewedAt, query.reviewedTo())
.ge(query.outboundFrom() != null, WarehouseTransferOrder::getOutboundAt, query.outboundFrom())
.le(query.outboundTo() != null, WarehouseTransferOrder::getOutboundAt, query.outboundTo())
.apply(query.measuredFrom() != null, "(SELECT MAX(qp.completed_at) FROM ll_warehouse_transfer_pallet qp WHERE qp.transfer_order_id=ll_warehouse_transfer_order.id AND qp.customer_id=ll_warehouse_transfer_order.customer_id) >= {0}", query.measuredFrom())
.apply(query.measuredTo() != null, "(SELECT MAX(qp.completed_at) FROM ll_warehouse_transfer_pallet qp WHERE qp.transfer_order_id=ll_warehouse_transfer_order.id AND qp.customer_id=ll_warehouse_transfer_order.customer_id) <= {0}", query.measuredTo())
.orderByDesc(WarehouseTransferOrder::getCreatedAt).orderByDesc(WarehouseTransferOrder::getId); .orderByDesc(WarehouseTransferOrder::getCreatedAt).orderByDesc(WarehouseTransferOrder::getId);
}
/** 日期区间统一校验,列表与数量汇总均拒绝反向区间。 */
private void validateQuantityRange(LocalDateTime from, LocalDateTime to, String name) {
if (from != null && to != null && from.isAfter(to)) throw badRequest(name + "开始时间不能晚于结束时间");
}
/** 客户侧完整筛选数量,不读取其他客户且不使用页码。 */
@Transactional(readOnly = true)
public com.baiyue.lastleg.transfer.dto.TransferQuantityTotals quantityTotals(String customerId, WarehouseTransferRequests.Query query) {
return transferQuantityService.merchantTotals(transferPageFilter(customerId, query, true), customerId);
}
@Transactional(readOnly = true)
public PageResult<WarehouseTransferResponses.Summary> page(String customerId, WarehouseTransferRequests.Query query) {
return page(customerId, query, true);
}
@Transactional(readOnly = true)
public PageResult<WarehouseTransferResponses.Summary> page(String customerId, WarehouseTransferRequests.Query query,
boolean includeDrafts) {
var wrapper = transferPageFilter(customerId, query, includeDrafts);
Page<WarehouseTransferOrder> result = transfers.selectPage(Page.of(query.page(), query.size()), wrapper); Page<WarehouseTransferOrder> result = transfers.selectPage(Page.of(query.page(), query.size()), wrapper);
Map<String, RelabelApplication> relabelApplicationsByNo = relabelApplications(result.getRecords()); Map<String, RelabelApplication> relabelApplicationsByNo = relabelApplications(result.getRecords());
Map<Long, LocalDateTime> measurementTimes = measurementTimes(result.getRecords()); Map<Long, LocalDateTime> measurementTimes = measurementTimes(result.getRecords());
@@ -961,7 +986,7 @@ public class WarehouseTransferService {
WarehouseTransferRequests.CountCartons request) { WarehouseTransferRequests.CountCartons request) {
WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId)); WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId));
if (transfer == null) throw notFound("当前仓没有该转运单"); if (transfer == null) throw notFound("当前仓没有该转运单");
if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer); if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer.getId(), transfer.getDeliveryMethod(), transfer.getTransferNo(), transfer.getInboundCode());
String customerId = transfer.getCustomerId(); String customerId = transfer.getCustomerId();
List<WarehouseTransferCarton> cartons = transferCartons.listOrder(customerId, transfer.getId()); List<WarehouseTransferCarton> cartons = transferCartons.listOrder(customerId, transfer.getId());
// 客户补箱后旧设备快照不可确认短收,必须刷新取得新增的预报箱唛。 // 客户补箱后旧设备快照不可确认短收,必须刷新取得新增的预报箱唛。
@@ -1096,7 +1121,11 @@ public class WarehouseTransferService {
/** 上门取货必须先送仓;按 PDA 绑定仓锁定后继续使用原测量流程。 */ /** 上门取货必须先送仓;按 PDA 绑定仓锁定后继续使用原测量流程。 */
private WarehouseTransferOrder requirePdaMeasuringTransfer(long warehouseId, String transferNo) { private WarehouseTransferOrder requirePdaMeasuringTransfer(long warehouseId, String transferNo) {
return pickupService.requireMeasuringSource(String.valueOf(warehouseId), transferNo); WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId));
if (transfer == null) throw notFound("仓储转运单不存在");
if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) throw conflict("当前转运单不允许测量");
pickupService.requireArrived(transfer.getId(), transfer.getDeliveryMethod(), transfer.getTransferNo(), transfer.getInboundCode());
return transfer;
} }
private void validatePalletMeasurement(BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal volume) { private void validatePalletMeasurement(BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal volume) {
@@ -1114,7 +1143,7 @@ public class WarehouseTransferService {
WarehouseTransferRequests.Palletize request) { WarehouseTransferRequests.Palletize request) {
WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(overseasWarehouseId)); WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(overseasWarehouseId));
if (transfer == null) throw notFound("Warehouse transfer order not found"); if (transfer == null) throw notFound("Warehouse transfer order not found");
if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer); if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer.getId(), transfer.getDeliveryMethod(), transfer.getTransferNo(), transfer.getInboundCode());
if (!String.valueOf(overseasWarehouseId).equals(transfer.getOperatingWarehouseId())) { if (!String.valueOf(overseasWarehouseId).equals(transfer.getOperatingWarehouseId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Warehouse transfer belongs to another warehouse"); throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Warehouse transfer belongs to another warehouse");
} }
@@ -1683,7 +1712,7 @@ public class WarehouseTransferService {
String transferNo, MultipartFile file) { String transferNo, MultipartFile file) {
WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(overseasWarehouseId)); WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(overseasWarehouseId));
if (transfer == null) throw notFound("Warehouse transfer order not found"); if (transfer == null) throw notFound("Warehouse transfer order not found");
if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer); if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer.getId(), transfer.getDeliveryMethod(), transfer.getTransferNo(), transfer.getInboundCode());
if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) { if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) {
throw conflict("Measurement photos can only be uploaded before palletization completes"); throw conflict("Measurement photos can only be uploaded before palletization completes");
} }
@@ -86,6 +86,7 @@ paths:
- $ref: '#/components/parameters/PalletNo' - $ref: '#/components/parameters/PalletNo'
post: post:
summary: Confirm actual disposal with owned pallet photos summary: Confirm actual disposal with owned pallet photos
description: PalletTask.skus返回actualQuantityEditable布尔值。仅普通库存未保存具体箱唛时为true,PDA展示相关托盘并要求填写实际废弃箱数;库存和费用按实际数结算,未废弃预留释放。已完成任务相同数量重放不重复扣减,修改数量拒绝。
x-permissions: [lastleg:pda:operate, ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR] x-permissions: [lastleg:pda:operate, ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR]
requestBody: requestBody:
required: true required: true
@@ -103,7 +104,7 @@ paths:
required: [itemId, actualPackages] required: [itemId, actualPackages]
properties: properties:
itemId: { type: string, pattern: '^[1-9][0-9]*$' } itemId: { type: string, pattern: '^[1-9][0-9]*$' }
actualPackages: { type: integer, minimum: 1, description: Must equal plannedPackages; must finish the application selection } actualPackages: { type: integer, minimum: 0, description: 无精确箱唛对应关系的普通库存允许0至plannedPackages的实际箱数,剩余预留释放;有具体选箱的明细必须等于plannedPackages }
scannedCartonMarks: scannedCartonMarks:
type: array type: array
uniqueItems: true uniqueItems: true
@@ -0,0 +1,94 @@
openapi: 3.0.3
info:
title: 货物废弃全部筛选数量统计
version: 1.1.0
paths:
/api/platform/v1/last-leg/cargo-discards/statistics:
get:
summary: 按平台允许的客户范围汇总全部筛选结果
description: 与废弃列表共用筛选条件,page和size不限制统计范围。未记录的实际数量、商品件数或托盘事实返回null。实际数取PDA确认事实,不从申请数或状态推算。
parameters:
- {name: customerId, in: query, schema: {type: string}}
- {$ref: '#/components/parameters/DiscardNo'}
- {$ref: '#/components/parameters/InboundCode'}
- {$ref: '#/components/parameters/Warehouse'}
- {$ref: '#/components/parameters/Status'}
- {$ref: '#/components/parameters/CreatedFrom'}
- {$ref: '#/components/parameters/CreatedTo'}
- {$ref: '#/components/parameters/CompletedFrom'}
- {$ref: '#/components/parameters/CompletedTo'}
- {$ref: '#/components/parameters/Page'}
- {$ref: '#/components/parameters/Size'}
responses:
'200':
description: 标准ApiResponse,data为全部筛选数量统计
content:
application/json:
schema: {$ref: '#/components/schemas/Envelope'}
/api/merchant/v1/last-leg/cargo-discards/statistics:
get:
summary: 按当前认证客户汇总全部筛选结果
description: 需要merchant:overseas-warehouse:discard:list权限;客户归属只取认证身份。完成时间筛选同时适用于列表。
parameters:
- {$ref: '#/components/parameters/DiscardNo'}
- {$ref: '#/components/parameters/InboundCode'}
- {$ref: '#/components/parameters/Warehouse'}
- {$ref: '#/components/parameters/Status'}
- {$ref: '#/components/parameters/CreatedFrom'}
- {$ref: '#/components/parameters/CreatedTo'}
- {$ref: '#/components/parameters/CompletedFrom'}
- {$ref: '#/components/parameters/CompletedTo'}
- {$ref: '#/components/parameters/Page'}
- {$ref: '#/components/parameters/Size'}
responses:
'200':
description: 标准ApiResponse,data为当前客户全部筛选统计
content:
application/json:
schema: {$ref: '#/components/schemas/Envelope'}
components:
parameters:
DiscardNo: {name: discardNo, in: query, schema: {type: string}}
InboundCode: {name: inboundCode, in: query, schema: {type: string}}
Warehouse: {name: overseasWarehouseId, in: query, schema: {type: string}}
Status: {name: status, in: query, schema: {type: string}}
CreatedFrom: {name: createdFrom, in: query, schema: {type: string, format: date-time}}
CreatedTo: {name: createdTo, in: query, schema: {type: string, format: date-time}}
CompletedFrom: {name: completedFrom, in: query, description: 客户确认完成时间下界,包含边界, schema: {type: string, format: date-time}}
CompletedTo: {name: completedTo, in: query, description: 客户确认完成时间上界,包含边界, schema: {type: string, format: date-time}}
Page: {name: page, in: query, required: true, schema: {type: integer, minimum: 1}}
Size: {name: size, in: query, required: true, schema: {type: integer, minimum: 1, maximum: 200}}
schemas:
SummaryDimensions:
type: object
description: 平台及客户废弃列表records与详情summary均返回这些维度;totalPackages保留作业阶段展示箱数,申请箱数须读取requestedPackages。
required: [skuCodes, skuCount, requestedPackages, actualPackages]
properties:
skuCodes: {type: array, items: {type: string}, description: 申请明细去重并排序后的全部SKU}
skuCount: {type: integer, description: 本单SKU种数}
requestedPackages: {type: integer, description: 申请物理箱数,同一混装箱跨SKU仅计一次}
actualPackages: {type: integer, nullable: true, description: PDA已确认实际物理箱数,未作业为null}
CandidateSku:
type: object
description: 库存仓位按物理箱计量,箱内全部SKU从真实实收JSON投影;完整混箱必须携带全部SKU明细,库存冻结和扣减按仓位及箱身份去重。
properties:
skuCode: {type: string}
productName: {type: string, nullable: true}
availablePackages: {type: string, description: 包含本SKU的可选物理箱数}
availablePieces: {type: string, nullable: true, description: 真实箱内本SKU件数,缺失时null,不按箱规推算}
Envelope:
type: object
properties:
code: {type: string}
data: {$ref: '#/components/schemas/Statistics'}
Statistics:
type: object
description: Long值遵循全局序列化配置,可能以十进制字符串返回;退货SKU按件汇总并按实物箱去重,转运件数只取所选箱实点SKU事实,其他缺失事实保留null。
properties:
orderCount: {type: string, description: 筛选订单票数}
skuCount: {type: integer, description: 筛选内去重SKU种数}
requestedPackages: {type: string, description: 申请物理箱数}
actualPackages: {type: string, nullable: true, description: 已确认实际物理箱数}
requestedPieces: {type: string, nullable: true, description: 申请商品件数}
actualPieces: {type: string, nullable: true, description: 已确认实际商品件数}
palletCount: {type: integer, nullable: true, description: 客户和仓范围内去重真实来源托盘数,缺失或散箱返回null}
@@ -4,6 +4,17 @@ info:
version: 1.0.0 version: 1.0.0
description: 全部筛选结果汇总,不受 page/size 影响。件、箱、SKU分别统计;未知不能解释为零,禁止固定箱规换算。 description: 全部筛选结果汇总,不受 page/size 影响。件、箱、SKU分别统计;未知不能解释为零,禁止固定箱规换算。
paths: paths:
/api/platform/v1/last-leg/relabel-applications/inventory-candidates:
get:
operationId: platformRelabelInventoryCandidates
description: 平台代客创建候选;权限platform:overseas-warehouse:relabel:create。客户和公共作业仓均必须显式选择,返回与客户创建端相同的InventoryCandidate结构。
parameters:
- {name: customerId, in: query, required: true, schema: {type: string}}
- {name: operationWarehouseId, in: query, required: true, schema: {type: string}}
- {name: inboundCode, in: query, schema: {type: string}}
responses:
'200':
description: 标准ApiResponse,data为正式库存候选数组;普通库存包含inventoryBalanceId/inboundOrderId/positions,退货包含returnCartonId/cartonMark/cartonSkuItems/cartonAvailablePackages。所有Long ID为十进制字符串。
/api/platform/v1/last-leg/relabel-applications/quantity-summary: /api/platform/v1/last-leg/relabel-applications/quantity-summary:
get: get:
operationId: platformRelabelQuantitySummary operationId: platformRelabelQuantitySummary
@@ -61,12 +72,16 @@ components:
type: object type: object
required: [orderCount, skuCount, knownLabelPieceCount, unknownPieceOrders, knownCartonCount, unknownCartonOrders, orders] required: [orderCount, skuCount, knownLabelPieceCount, unknownPieceOrders, knownCartonCount, unknownCartonOrders, orders]
properties: properties:
orderCount: {type: integer, format: int64, minimum: 0} orderCount: {type: string, pattern: '^[0-9]+$'}
skuCount: {type: integer, format: int64, minimum: 0, description: 全部筛选结果的不同旧SKU数} skuCount: {type: string, pattern: '^[0-9]+$', description: 全部筛选结果的不同旧SKU数}
knownLabelPieceCount: {type: integer, format: int64, minimum: 0} knownLabelPieceCount: {type: string, pattern: '^[0-9]+$'}
unknownPieceOrders: {type: integer, format: int64, minimum: 0} unknownPieceOrders: {type: string, pattern: '^[0-9]+$'}
knownCartonCount: {type: integer, format: int64, minimum: 0} knownCartonCount: {type: string, pattern: '^[0-9]+$'}
unknownCartonOrders: {type: integer, format: int64, minimum: 0} unknownCartonOrders: {type: string, pattern: '^[0-9]+$'}
knownSourcePalletCount: {type: string, pattern: '^[0-9]+$', description: 已知完整原托快照按仓库和托盘码在全部筛选单据间去重}
unknownSourcePalletOrders: {type: string, pattern: '^[0-9]+$'}
knownTargetPalletCount: {type: string, pattern: '^[0-9]+$', description: 本轮完整目标快照按仓库和托盘码去重;返工旧轮目标不计}
unknownTargetPalletOrders: {type: string, pattern: '^[0-9]+$'}
orders: orders:
type: object type: object
description: 以换标业务单号为键,覆盖全部筛选单据 description: 以换标业务单号为键,覆盖全部筛选单据
@@ -74,10 +89,15 @@ components:
Quantity: Quantity:
type: object type: object
properties: properties:
labelPieceCount: {type: integer, format: int64, nullable: true, description: 按件任务采用通知件数;箱任务采用SKU子项件数;重复关联或缺失为null} labelPieceCount: {type: string, pattern: '^[0-9]+$', nullable: true, description: 按件任务采用通知件数;箱任务采用SKU子项件数;重复关联或缺失为null}
cartonCount: {type: integer, format: int64, nullable: true, description: 箱级任务按箱唛去重;按件来源标识不作为箱数} cartonCount: {type: string, pattern: '^[0-9]+$', nullable: true, description: 箱级任务按箱唛去重;按件任务仅在全部精确来源快照都是BOX时按来源主键去重,PALLET/PARCEL不冒充箱数}
skuCount: {type: integer, minimum: 0} skuCount: {type: integer, minimum: 0}
duplicateCarton: {type: boolean, description: 历史重复任务关联提示,不将同一来源不同SKU按件任务视为重复} duplicateCarton: {type: boolean, description: 历史重复任务关联提示,不将同一来源不同SKU按件任务视为重复}
quantityUnit: {type: string, enum: [PIECE, CARTON, UNKNOWN]} quantityUnit: {type: string, enum: [PIECE, CARTON, UNKNOWN]}
sourcePalletCount: {type: integer, format: int64, nullable: true, description: 缺少不可变原托快照时为null} sourcePalletCount: {type: string, pattern: '^[0-9]+$', nullable: true, description: 缺少不可变原托快照时为null}
targetPalletCount: {type: integer, format: int64, nullable: true, description: 未提供汇总,详情以本轮有效绑定历史为准} targetPalletCount: {type: string, pattern: '^[0-9]+$', nullable: true, description: 每个任务本轮最后一次可信绑定的不同目标托盘数;未绑定完整为null}
sourceUnitCounts:
type: object
nullable: true
description: 首次认领时已清点且精确关联的退货来源主键按BOX/PARCEL/PALLET分类去重;任一来源缺失时为null,不按标识外形猜测
additionalProperties: {type: string, pattern: '^[0-9]+$'}
@@ -0,0 +1,60 @@
openapi: 3.0.3
info:
title: 退货全筛选统计与托盘无效果恢复
version: 1.0.0
paths:
/api/merchant/v1/last-leg/return-orders/statistics:
get:
summary: 当前客户退货全筛选统计
description: 使用与退货分页列表一致的 returnNo、status、skuCode、trackingNo 条件。客户由认证身份确定,忽略客户端归属输入。totals 不受 page/size 影响;pageDimensions 仅对应当前页。SKU 跨订单按去空白大写后的编码去重。件、箱、包裹、托独立统计;未提供的预报载体数不推算为零。需要 MERCHANT_RETURN_ORDER_LIST 对应正式权限。
operationId: merchantReturnOrderStatistics
responses:
'200':
description: ApiResponse,data 为 totals 与 pageDimensions;失败按统一响应契约返回。
/api/platform/v1/last-leg/return-orders/statistics:
get:
summary: 尾程筛选集合中原生退货订单的统计
description: 需要 platform:overseas-warehouse:last-mile-order:list。完整复用尾程列表筛选;totals 只聚合其中有原生退货记录的订单。pageDimensions 对应混合尾程列表当前页中的退货行,不单独分页退货子集。实收载体来自签收事实,出库不扣减历史签收数量。
operationId: platformReturnOrderStatistics
responses:
'200':
description: ApiResponse,data 为 totals 与 pageDimensions。
/api/platform/v1/last-leg/pallet-operation-recoveries/{id}:
parameters:
- in: path
name: id
required: true
schema: { type: integer, format: int64 }
get:
summary: 管理员只读核对原托盘作业
description: 需要 lastleg:inventory:manage,返回原状态、请求哈希、远端意图、原错误和更新时间,不返回会话凭据。
operationId: inspectPalletOperationRecovery
responses:
'200': { description: 原作业技术审计状态 }
/api/platform/v1/last-leg/pallet-operation-recoveries/{id}/close-no-effect:
parameters:
- in: path
name: id
required: true
schema: { type: integer, format: int64 }
post:
summary: 审计关闭无远端效果的超时换标组托
description: 需要 lastleg:inventory:manage。仅 RELABEL_PALLET_DISASSEMBLE、PROCESSING 或 FAILED_COMPENSATED、超过15分钟、版本一致、无结果且无任何远端意图允许关闭。未知/已确认/损坏远端记录均拒绝。原行行锁与远端意图条件写互斥;旧线程失败写不能覆盖恢复终态。原请求哈希及原错误不变,恢复管理员、原因、幂等键、原状态和时间写入原 result_json。相同管理员/键/原因重放返回同一审计,不另造业务成功结果。无需新增表,不写库存或远端实托。
operationId: closePalletOperationNoEffect
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [warehouseId, expectedUpdatedAt, idempotencyKey, reason]
properties:
warehouseId: { type: integer, format: int64, minimum: 1 }
expectedUpdatedAt: { type: string, format: date-time }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
reason: { type: string, minLength: 1, maxLength: 1000 }
responses:
'200': { description: ApiResponse 恢复审计或正式业务拒绝 }
'403': { description: 权限不足 }
'404': { description: 原作业不存在 }
'409': { description: 原状态、版本、归属或远端效果不满足恢复条件 }
@@ -0,0 +1,120 @@
openapi: 3.0.3
info:
title: 仓储转运与混箱库存数量
version: 1.0.0
description: 件、箱、托分开记录。所有Long数量序列化为十进制字符串;未知件数保持null,空筛选返回零。同托部分出库可同时属于历史实收、剩余、出库集合,禁止托数相减。
paths:
/api/platform/v1/last-leg/orders/warehouse-transfer-quantities:
get:
operationId: platformWarehouseTransferQuantityTotals
description: 权限platform:overseas-warehouse:last-mile-order:list,平台访问域与主订单列表一致。复用主订单全部Query筛选及relabelNo,额外限定WAREHOUSE_TRANSFER;page/size不参与汇总。
responses:
'200':
description: ApiResponse.data为TransferQuantityTotals。
content:
application/json:
schema: { $ref: '#/components/schemas/TotalsEnvelope' }
/api/merchant/v1/last-leg/warehouse-transfers/quantities:
get:
operationId: merchantWarehouseTransferQuantityTotals
description: 权限merchant:overseas-warehouse:transfer:list,客户范围只从会话获取。复用转运列表完整筛选,包括transferNo/inboundCode/operatingWarehouseId/fbaWarehouseCode/status/needRelabel和创建、审核、清点完成、出库日期区间;日期区间上界包含端点。page/size不参与汇总。
responses:
'200':
description: ApiResponse.data为TransferQuantityTotals。
content:
application/json:
schema: { $ref: '#/components/schemas/TotalsEnvelope' }
/api/platform/v1/last-leg/inventory/piece-projection:
get:
operationId: platformInventoryPieceProjection
description: 权限platform:overseas-warehouse:inventory:list,与库存列表相同平台客户访问域及BalanceQuery完整筛选,不分页。按原箱清点及正式目的仓签收凭据投影当前未占用可用库存。每仓位精确箱映射数量须等于available_packages,箱内SKU件数须等于箱总清点件数;缺失、不一致、无法追溯的非转运库存均为未知,不按箱规或首SKU推算。通用余额MIXED也逐箱读取全部SKU。SKU查询可命中混箱第二SKU。
responses:
'200':
description: ApiResponse.data为InventoryPieceProjection。
content:
application/json:
schema:
type: object
properties:
code: { type: string }
data: { $ref: '#/components/schemas/InventoryPieceProjection' }
components:
schemas:
Count:
type: string
pattern: '^[0-9]+$'
NullableCount:
type: string
nullable: true
pattern: '^[0-9]+$'
TransferQuantityView:
type: object
description: 主订单列表与详情summary.warehouseTransferQuantity,仅转运订单提供。新到仓历史实收取counted_at非空且COUNTED/DISCARD_PENDING/DISCARDED/SHIPPED原箱;库存转运实收取正式目的仓签收凭据,件数取精确原箱清点。
properties:
lastlegOrderId: { $ref: '#/components/schemas/Count' }
declaredPieceCount: { $ref: '#/components/schemas/NullableCount' }
receivedPieceCount: { $ref: '#/components/schemas/NullableCount' }
declaredCartonCount: { $ref: '#/components/schemas/Count' }
receivedCartonCount: { $ref: '#/components/schemas/Count' }
receivedPalletCount: { $ref: '#/components/schemas/Count' }
availableCartonCount: { type: string, description: 原箱当前COUNTED数,非扣除其他业务预留后的可用库存。真实可用数读取库存投影。 }
shippedCartonCount: { $ref: '#/components/schemas/Count' }
remainingPalletCount: { $ref: '#/components/schemas/Count' }
shippedPalletCount: { $ref: '#/components/schemas/Count' }
TransferQuantityTotals:
allOf:
- { $ref: '#/components/schemas/TransferQuantityView' }
- type: object
properties:
orderCount: { $ref: '#/components/schemas/Count' }
knownDeclaredPieceCount: { $ref: '#/components/schemas/Count' }
unknownDeclaredOrderCount: { $ref: '#/components/schemas/Count' }
knownReceivedPieceCount: { $ref: '#/components/schemas/Count' }
unknownReceivedOrderCount: { $ref: '#/components/schemas/Count' }
TotalsEnvelope:
type: object
properties:
code: { type: string }
data: { $ref: '#/components/schemas/TransferQuantityTotals' }
Sku:
type: object
properties:
skuCode: { type: string }
quantity: { $ref: '#/components/schemas/Count' }
InventoryPieceProjection:
type: object
properties:
availablePieceCount: { $ref: '#/components/schemas/NullableCount' }
knownAvailablePieceCount: { $ref: '#/components/schemas/Count' }
unknownCartonCount: { $ref: '#/components/schemas/Count' }
availableCartonCount: { $ref: '#/components/schemas/Count' }
availablePalletCount: { $ref: '#/components/schemas/Count' }
groups:
type: array
items:
type: object
properties:
customerId: { type: string }
warehouseId: { type: string }
destinationCode: { type: string, nullable: true }
anchorSkuCode: { type: string, nullable: true, description: 原余额记账SKU,可能为MIXED。完整SKU在skuContents中。 }
availablePieceCount: { $ref: '#/components/schemas/NullableCount' }
knownAvailablePieceCount: { $ref: '#/components/schemas/Count' }
unknownCartonCount: { $ref: '#/components/schemas/Count' }
availableCartonCount: { $ref: '#/components/schemas/Count' }
availablePalletCount: { $ref: '#/components/schemas/Count' }
skuContents:
type: array
items: { $ref: '#/components/schemas/Sku' }
cartons:
type: array
items:
type: object
properties:
cartonMark: { type: string }
inventoryPositionId: { type: string }
palletId: { type: string, nullable: true }
quantity: { $ref: '#/components/schemas/NullableCount' }
items:
type: array
items: { $ref: '#/components/schemas/Sku' }
@@ -0,0 +1,118 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.entity.*;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.*;
import com.baiyue.lastleg.inventory.mapper.*;
import com.baiyue.lastleg.relabel.entity.*;
import com.baiyue.lastleg.relabel.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class RelabelOutboundPickingTest {
private DeliveryTransferService service;
private InventoryCommandService commands;
private InventoryPosition position;
private InventoryBalance balance;
private OutboundPalletItem item;
private OutboundPdaPickMapper picks;
private OutboundPdaTaskMapper tasks;
private OutboundOrderMapper orders;
private OutboundPalletItemMapper pallets;
private InventoryPositionMapper positions;
private InventoryBalanceMapper balances;
private RelabelApplicationMapper applications;
private RelabelItemMapper items;
/** 构造返工后新目标位:两箱冻结,账面混合SKU,与空出库SKU不作通配匹配。 */
@BeforeEach void prepare() {
service = new DeliveryTransferService(); commands = mock(InventoryCommandService.class);
picks=mock(OutboundPdaPickMapper.class); tasks=mock(OutboundPdaTaskMapper.class);
orders=mock(OutboundOrderMapper.class); pallets=mock(OutboundPalletItemMapper.class);
positions=mock(InventoryPositionMapper.class); balances=mock(InventoryBalanceMapper.class);
applications=mock(RelabelApplicationMapper.class); items=mock(RelabelItemMapper.class);
Map.of("inventoryCommands",commands,"pdaPicks",picks,"pdaTasks",tasks,"outboundOrders",orders,
"outboundPallets",pallets,"inventoryPositions",positions,"inventoryBalances",balances,
"relabelApplications",applications,"relabelItems",items,"objectMapper",new ObjectMapper())
.forEach((name,value)->ReflectionTestUtils.setField(service,name,value));
var outbound=new OutboundOrder(); outbound.setId(1L); outbound.setCustomerId("C");
outbound.setOverseasWarehouseId("W"); outbound.setOutboundNo("OUT"); outbound.setOutboundType("RELABEL_OUTBOUND");
var task=new OutboundPdaTask(); task.setId(2L); task.setCustomerId("C"); task.setOutboundOrderId(1L);
task.setTaskNo("TASK"); task.setStatus("PENDING");
item=new OutboundPalletItem(); item.setId(3L); item.setCustomerId("C"); item.setOutboundOrderId(1L);
item.setWarehousePalletId(4L); item.setPalletNo("NEW-TARGET"); item.setInboundCode("IN");
item.setPackageCount(2); item.setPickedPackageCount(0); item.setCartonMarks("BOX-A,BOX-B");
item.setOrderRefs("""
{"sourceType":"RELABEL_ORDER","relabelNo":"RL","sourceInventoryPositionId":9,
"items":[{"itemId":11,"cartonMark":"BOX-A","packages":1},{"itemId":12,"cartonMark":"BOX-B","packages":1}]}
""");
position=new InventoryPosition(); position.setId(9L); position.setCustomerId("C"); position.setBalanceId(8L);
position.setOverseasWarehouseId("W"); position.setPalletId(4L); position.setPalletNo("NEW-TARGET");
position.setAvailablePackages(0L); position.setHoldPackages(2L);
balance=new InventoryBalance(); balance.setId(8L); balance.setCustomerId("C");
balance.setOverseasWarehouseId("W"); balance.setInboundCode("IN"); balance.setSkuCode("MIXED");
when(tasks.lockByNoAndWarehouse("TASK","W")).thenReturn(task);
when(tasks.findByNoAndWarehouse("TASK","W")).thenReturn(task);
when(orders.selectById(1L)).thenReturn(outbound); when(orders.lockByNo("C","OUT")).thenReturn(outbound);
when(pallets.findByOutbound("C",1L)).thenReturn(List.of(item)); when(pallets.selectById(3L)).thenReturn(item);
when(positions.listPallet("C",4L)).thenReturn(List.of(position)); when(positions.selectById(9L)).thenReturn(position);
when(balances.selectById(8L)).thenReturn(balance);
var app=new RelabelApplication(); app.setId(10L); app.setRelabelNo("RL"); app.setStatus("READY_FOR_OUTBOUND");
when(applications.lockByOutbound("C","OUT")).thenReturn(app);
var first=new RelabelItem(); first.setId(11L); first.setCartonMark("BOX-A"); first.setNotifiedPackageCount(1);
var second=new RelabelItem(); second.setId(12L); second.setCartonMark("BOX-B"); second.setNotifiedPackageCount(1);
when(items.selectList(any())).thenReturn(List.of(first,second));
}
/** 指定箱从冻结到出库数量守恒;整托重扫不重复释放或出库。 */
@Test void picksExactFrozenBoxesAndReplayDoesNotShipAgain() {
assertEquals(List.of("BOX-A","BOX-B"),service.pdaTaskDetail("W","TASK").items().get(0).positions()
.stream().map(value->value.cartonMark()).toList());
service.pdaPickPallet("W","U","TASK","NEW-TARGET",new DeliveryRequests.PdaPalletPick("KEY"));
service.pdaPickPallet("W","U","TASK","NEW-TARGET",new DeliveryRequests.PdaPalletPick("KEY"));
var release=org.mockito.ArgumentCaptor.forClass(InventoryCommandRequests.Move.class);
verify(commands,times(2)).release(eq("C"),eq("U"),release.capture());
assertEquals(2,release.getAllValues().stream().mapToLong(InventoryCommandRequests.Move::packages).sum());
assertTrue(release.getAllValues().stream().allMatch(move->move.positionId()==9L&&"RELABEL:RL".equals(move.sourceEventId())));
var shipped=org.mockito.ArgumentCaptor.forClass(InventoryCommandRequests.Move.class);
verify(commands).ship(eq("C"),eq("U"),shipped.capture());
assertEquals(2,shipped.getValue().packages()); assertEquals(9L,shipped.getValue().positionId());
verify(commands).allocate(eq("C"),eq("U"),any()); verify(commands).pick(eq("C"),eq("U"),any());
verify(picks).insert(any(OutboundPdaPick.class)); assertEquals(2,item.getPickedPackageCount());
}
/** 其他位置、客户、仓、入仓或不足冻结均拒绝,不能用空SKU放宽范围。 */
@ParameterizedTest @ValueSource(strings={"POSITION","CUSTOMER","WAREHOUSE","INBOUND","HOLD","SNAPSHOT"})
void rejectsUnrelatedOrIncompleteSourcesWithoutInventoryEffects(String invalid) {
switch(invalid) {
case "POSITION" -> position.setId(99L);
case "CUSTOMER" -> balance.setCustomerId("OTHER");
case "WAREHOUSE" -> position.setOverseasWarehouseId("OTHER");
case "INBOUND" -> balance.setInboundCode("OTHER");
case "HOLD" -> position.setHoldPackages(1L);
case "SNAPSHOT" -> item.setOrderRefs("{\"sourceType\":\"RELABEL_ORDER\"}");
}
assertTrue(service.pdaTaskDetail("W","TASK").items().get(0).positions().isEmpty());
assertThrows(ResponseStatusException.class,()->service.pdaPickPallet("W","U","TASK","NEW-TARGET",new DeliveryRequests.PdaPalletPick("KEY")));
verifyNoInteractions(commands,picks);
}
/** 同箱唛也不能代替来源任务主键,损坏ID必须在释放冻结前拒绝。 */
@Test void rejectsWrongSnapshotItemIdBeforeReleasingAnyStock() {
item.setOrderRefs(item.getOrderRefs().replace("\"itemId\":11", "\"itemId\":999"));
assertEquals(2,service.pdaTaskDetail("W","TASK").items().get(0).positions().size());
assertThrows(ResponseStatusException.class,()->service.pdaPickPallet("W","U","TASK","NEW-TARGET",new DeliveryRequests.PdaPalletPick("KEY")));
verifyNoInteractions(commands); verify(picks,never()).insert(any(OutboundPdaPick.class));
}
}
@@ -16,6 +16,50 @@ import static org.junit.jupiter.api.Assertions.*;
/** 使用真实 SQL 核对转运实收箱与库存仓位的关联及废弃状态流转。 */ /** 使用真实 SQL 核对转运实收箱与库存仓位的关联及废弃状态流转。 */
class CargoDiscardTransferCartonMapperTest { class CargoDiscardTransferCartonMapperTest {
/** MIXED 是整托物理余额标记;精确返回剩余实箱及真实 SKU,不能降级成整仓位或混入别的来源。 */
@Test
void mixedPhysicalBalanceKeepsOnlyItsRemainingCountedCartons() {
var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var db = new JdbcTemplate(source);
db.execute("CREATE TABLE ll_inbound_plan_carton(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(30),received_at TIMESTAMP,destination_warehouse_code VARCHAR(20),carton_mark VARCHAR(80),sku_code VARCHAR(20))");
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT,destination_warehouse_code VARCHAR(20),sku_code VARCHAR(20))");
db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(20),balance_id BIGINT,pallet_id BIGINT,batch_no VARCHAR(80),overseas_warehouse_id VARCHAR(20))");
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT,transfer_no VARCHAR(80),operating_warehouse_id VARCHAR(20))");
db.execute("CREATE TABLE ll_warehouse_transfer_pallet(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(30))");
db.execute("CREATE TABLE ll_warehouse_transfer_item(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,sku_code VARCHAR(20))");
db.execute("CREATE TABLE ll_warehouse_transfer_carton(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,transfer_item_id BIGINT,transfer_pallet_id BIGINT,status VARCHAR(30),carton_mark VARCHAR(80),updated_at TIMESTAMP,sku_items_json VARCHAR(2000))");
db.update("INSERT INTO ll_inventory_balance VALUES(1,'C',10,'FBA','MIXED')");
db.update("INSERT INTO ll_inventory_position VALUES(41,'C',1,100,'TRANSFER','W'),(42,'C',1,100,'OTHER-BATCH','W'),(43,'C',1,100,'TRANSFER','OTHER-WAREHOUSE')");
db.update("INSERT INTO ll_warehouse_transfer_order VALUES(10,'C',10,'TRANSFER','W'),(20,'C',10,'OTHER-ORDER','W')");
db.update("INSERT INTO ll_warehouse_transfer_pallet VALUES(11,'C',10,100,'COMPLETED'),(12,'C',10,200,'COMPLETED'),(13,'C',10,100,'BOUND'),(14,'C',20,100,'COMPLETED')");
db.update("INSERT INTO ll_warehouse_transfer_item VALUES(21,'C',10,'A'),(22,'C',10,'B'),(23,'C',20,'FOREIGN-ORDER')");
db.update("INSERT INTO ll_warehouse_transfer_carton(id,customer_id,transfer_order_id,transfer_item_id,transfer_pallet_id,status,carton_mark,sku_items_json) VALUES " +
"(1,'C',10,21,11,'COUNTED','REMAINING-A','[{\"skuCode\":\"A\",\"quantity\":2}]')," +
"(2,'C',10,22,11,'DISPATCHED','MOVED-B','[{\"skuCode\":\"B\",\"quantity\":8}]')," +
"(3,'C',10,21,12,'COUNTED','OTHER-PALLET',NULL),(4,'OTHER',10,21,11,'COUNTED','OTHER-CUSTOMER',NULL)," +
"(5,'C',10,21,11,'EXPECTED','FORECAST',NULL),(6,'C',10,21,11,'DISCARD_PENDING','HELD',NULL)," +
"(7,'C',10,21,13,'COUNTED','NOT-STOCKED',NULL),(8,'C',20,23,14,'COUNTED','OTHER-ORDER',NULL)," +
"(9,'C',10,23,11,'COUNTED','ITEM-ORDER-MISMATCH',NULL)");
var config = new Configuration(new Environment("test", new JdbcTransactionFactory(), source));
config.setMapUnderscoreToCamelCase(true);
config.addMapper(CargoDiscardInventoryMapper.class);
try (var session = new SqlSessionFactoryBuilder().build(config).openSession(true)) {
var mapper = session.getMapper(CargoDiscardInventoryMapper.class);
var rows = mapper.genericCartons("C", 41L);
assertEquals(List.of("REMAINING-A"), rows.stream().map(c -> c.getCartonMark()).toList());
assertEquals("A", rows.get(0).getSkuCode());
assertEquals("[{\"skuCode\":\"A\",\"quantity\":2}]", rows.get(0).getSkuItemsJson());
assertTrue(mapper.genericCartons("OTHER", 41L).isEmpty());
assertTrue(mapper.genericCartons("C", 42L).isEmpty());
assertTrue(mapper.genericCartons("C", 43L).isEmpty());
db.update("UPDATE ll_warehouse_transfer_carton SET status='COUNTED',sku_items_json='[{\"skuCode\":\"A\",\"quantity\":3},{\"skuCode\":\"B\",\"quantity\":5}]' WHERE id=2");
session.clearCache();
assertEquals(List.of("REMAINING-A", "MOVED-B"), mapper.genericCartons("C", 41L).stream().map(c -> c.getCartonMark()).toList());
assertEquals("B", mapper.genericCartons("C", 41L).get(1).getSkuCode());
assertTrue(mapper.genericCartons("C", 41L).get(1).getSkuItemsJson().contains("\"quantity\":5"));
}
}
/** 同批多托盘、多 SKU 时仅返回本仓位实收箱,排除预报、短收、占用及其他客户的箱。 */ /** 同批多托盘、多 SKU 时仅返回本仓位实收箱,排除预报、短收、占用及其他客户的箱。 */
@Test @Test
void receivedBoxesFollowTheirActualPalletAndSku() { void receivedBoxesFollowTheirActualPalletAndSku() {
@@ -27,7 +71,7 @@ class CargoDiscardTransferCartonMapperTest {
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT,transfer_no VARCHAR(80),operating_warehouse_id VARCHAR(20))"); db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT,transfer_no VARCHAR(80),operating_warehouse_id VARCHAR(20))");
db.execute("CREATE TABLE ll_warehouse_transfer_pallet(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(30))"); db.execute("CREATE TABLE ll_warehouse_transfer_pallet(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(30))");
db.execute("CREATE TABLE ll_warehouse_transfer_item(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,sku_code VARCHAR(20))"); db.execute("CREATE TABLE ll_warehouse_transfer_item(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,sku_code VARCHAR(20))");
db.execute("CREATE TABLE ll_warehouse_transfer_carton(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,transfer_item_id BIGINT,transfer_pallet_id BIGINT,status VARCHAR(30),carton_mark VARCHAR(80),updated_at TIMESTAMP)"); db.execute("CREATE TABLE ll_warehouse_transfer_carton(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,transfer_item_id BIGINT,transfer_pallet_id BIGINT,status VARCHAR(30),carton_mark VARCHAR(80),updated_at TIMESTAMP,sku_items_json VARCHAR(2000))");
db.update("INSERT INTO ll_inventory_balance VALUES(1,'C',10,'FBA','SKU'),(2,'C',10,'FBA','OTHER')"); db.update("INSERT INTO ll_inventory_balance VALUES(1,'C',10,'FBA','SKU'),(2,'C',10,'FBA','OTHER')");
db.update("INSERT INTO ll_inventory_position VALUES(41,'C',1,100,'ZY-BYWL-261019194301','W'),(42,'C',1,200,'ZY-BYWL-261019194301','W'),(43,'C',2,100,'ZY-BYWL-261019194301','W')"); db.update("INSERT INTO ll_inventory_position VALUES(41,'C',1,100,'ZY-BYWL-261019194301','W'),(42,'C',1,200,'ZY-BYWL-261019194301','W'),(43,'C',2,100,'ZY-BYWL-261019194301','W')");
db.update("INSERT INTO ll_warehouse_transfer_order VALUES(10,'C',10,'ZY-BYWL-261019194301','W')"); db.update("INSERT INTO ll_warehouse_transfer_order VALUES(10,'C',10,'ZY-BYWL-261019194301','W')");
@@ -48,6 +92,10 @@ class CargoDiscardTransferCartonMapperTest {
assertEquals(List.of("OTHER-PALLET"), mapper.genericCartons("C", 42L).stream().map(c -> c.getCartonMark()).toList()); assertEquals(List.of("OTHER-PALLET"), mapper.genericCartons("C", 42L).stream().map(c -> c.getCartonMark()).toList());
assertEquals(List.of("OTHER-SKU"), mapper.genericCartons("C", 43L).stream().map(c -> c.getCartonMark()).toList()); assertEquals(List.of("OTHER-SKU"), mapper.genericCartons("C", 43L).stream().map(c -> c.getCartonMark()).toList());
assertTrue(mapper.genericCartons("OTHER", 41L).isEmpty()); assertTrue(mapper.genericCartons("OTHER", 41L).isEmpty());
db.update("UPDATE ll_warehouse_transfer_carton SET sku_items_json=? WHERE id=1", "[{\"skuCode\":\"SKU\",\"quantity\":5},{\"skuCode\":\"OTHER\",\"quantity\":7}]");
session.clearCache();
assertEquals(List.of("OTHER-SKU"), mapper.genericCartons("C", 43L).stream().map(c -> c.getCartonMark()).toList());
assertTrue(mapper.genericCartons("C", 41L).get(0).getSkuItemsJson().contains("OTHER"));
assertEquals(1, mapper.changeTransferCartonStatus("C", 2L, "COUNTED", "DISCARD_PENDING")); assertEquals(1, mapper.changeTransferCartonStatus("C", 2L, "COUNTED", "DISCARD_PENDING"));
assertEquals(1, mapper.genericCartons("C", 41L).size()); assertEquals(1, mapper.genericCartons("C", 41L).size());
assertEquals(0, mapper.changeTransferCartonStatus("OTHER", 2L, "DISCARD_PENDING", "COUNTED")); assertEquals(0, mapper.changeTransferCartonStatus("OTHER", 2L, "DISCARD_PENDING", "COUNTED"));
@@ -0,0 +1,43 @@
package com.baiyue.lastleg.discard.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
import com.baiyue.lastleg.discard.service.CargoDiscardService;
import org.junit.jupiter.api.Test;
import org.springframework.core.MethodParameter;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import org.springframework.web.bind.support.WebDataBinderFactory;
import java.time.LocalDateTime;
import java.util.Set;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** 使用真实 MVC 参数绑定防止查询构造器重载导致列表和统计全部失败。 */
class CargoDiscardQueryBindingTest {
/** 客户归属取认证身份,完成时间同时传入分页和全筛选统计。 */
@Test void mvcBindsCompletionDatesForPageAndStatistics() throws Exception {
var service = mock(CargoDiscardService.class);
var controller = new MerchantCargoDiscardController();
ReflectionTestUtils.setField(controller, "service", service);
var mvc = MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(new HandlerMethodArgumentResolver() {
@Override public boolean supportsParameter(MethodParameter parameter) { return parameter.getParameterType() == AuthenticatedUser.class; }
@Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer container, NativeWebRequest request, WebDataBinderFactory binder) {
return new AuthenticatedUser(1L, 100L, 100L, 9L, "fixture", "MERCHANT", "WEB", Set.of());
}
}).build();
for (String suffix : new String[]{"", "/statistics"}) {
mvc.perform(get("/api/merchant/v1/last-leg/cargo-discards" + suffix)
.param("page", "1").param("size", "1").param("customerId", "OTHER")
.param("completedFrom", "2099-01-01T00:00:00")).andExpect(status().isOk());
}
var query = new CargoDiscardRequests.Query(1, 1, null, null, null, null, null, null,
LocalDateTime.of(2099, 1, 1, 0, 0), null);
verify(service).page("100", query);
verify(service).statistics("100", query);
}
}
@@ -42,15 +42,17 @@ class PdaCargoDiscardServiceTest {
@BeforeEach void setup() { @BeforeEach void setup() {
service = new PdaCargoDiscardService(); service = new PdaCargoDiscardService();
var repository = new com.baiyue.lastleg.discard.repository.CargoDiscardPdaRepository();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "outboundService", org.mockito.Mockito.mock(com.baiyue.lastleg.discard.service.CargoDiscardOutboundService.class)); ReflectionTestUtils.setField(service, "outboundService", org.mockito.Mockito.mock(com.baiyue.lastleg.discard.service.CargoDiscardOutboundService.class));
ReflectionTestUtils.setField(service, "photoService", photoService); ReflectionTestUtils.setField(service, "photoService", photoService);
ReflectionTestUtils.setField(service, "applications", applications); ReflectionTestUtils.setField(repository, "applications", applications);
ReflectionTestUtils.setField(service, "items", items); ReflectionTestUtils.setField(repository, "items", items);
ReflectionTestUtils.setField(service, "cartons", cartons); ReflectionTestUtils.setField(repository, "cartons", cartons);
ReflectionTestUtils.setField(service, "profiles", profiles); ReflectionTestUtils.setField(repository, "profiles", profiles);
ReflectionTestUtils.setField(service, "adjustments", adjustments); ReflectionTestUtils.setField(service, "adjustments", adjustments);
ReflectionTestUtils.setField(service, "discardService", discardService); ReflectionTestUtils.setField(service, "discardService", discardService);
ReflectionTestUtils.setField(service, "returnCartons", returnCartons); ReflectionTestUtils.setField(repository, "returnCartons", returnCartons);
application = new CargoDiscardApplication(); application.setId(1L); application.setDiscardNo("CD-1"); application = new CargoDiscardApplication(); application.setId(1L); application.setDiscardNo("CD-1");
application.setCustomerId("CUSTOMER"); application.setOverseasWarehouseId("99"); application.setCustomerId("CUSTOMER"); application.setOverseasWarehouseId("99");
application.setStatus(CargoDiscardService.APPROVED); application.setFeePerPackage(new BigDecimal("5.00")); application.setStatus(CargoDiscardService.APPROVED); application.setFeePerPackage(new BigDecimal("5.00"));
@@ -171,6 +173,33 @@ class PdaCargoDiscardServiceTest {
private PdaCargoDiscardDtos.Confirm looseRequest(List<String> marks, boolean bulk) { private PdaCargoDiscardDtos.Confirm looseRequest(List<String> marks, boolean bulk) {
return new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 2)), List.of(101L), marks, bulk); return new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 2)), List.of(101L), marks, bulk);
} }
/** 无箱唛对应的仓位允许实际少弃或未弃,完工后同载荷重放不再结算。 */
@ParameterizedTest @ValueSource(ints = {0, 1, 2})
void unboundPalletUsesActualQuantityForInventoryFeeAndReplay(int actual) {
var stockService = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class);
ReflectionTestUtils.setField(service, "stockService", stockService);
item.setSourceType("INVENTORY"); item.setInventoryPositionId(42L); item.setDiscardPackages(3);
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition();
position.setOverseasWarehouseId("99"); position.setPalletNo("PALLET-1");
when(stockService.position("CUSTOMER", 42L)).thenReturn(position);
when(stockService.cartonMarks(item)).thenReturn(List.of());
when(stockService.actualQuantityEditable(item)).thenReturn(true);
when(applications.selectList(any())).thenReturn(List.of(application));
var task = service.resolve(identity, "PALLET-1", "CD-1");
assertTrue(task.skus().get(0).actualQuantityEditable()); assertEquals("PALLET", task.scanMode());
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(4)));
verifyNoInteractions(photoService);
var completed = service.confirm(identity, "CD-1", "PALLET-1", request(actual));
assertEquals(actual, completed.skus().get(0).actualPackages());
assertEquals(actual, application.getTotalPackages());
assertEquals(new BigDecimal("5.00").multiply(BigDecimal.valueOf(actual)), application.getTotalFee());
service.confirm(identity, "CD-1", "PALLET-1", request(actual));
verify(stockService, times(1)).discard("CUSTOMER", "OP", application, item, actual);
verifyNoInteractions(adjustments);
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(3)));
verify(items, times(1)).updateById(item);
}
@Test void looseTaskExposesAllRealMarksAndRejectsMissingDuplicateAndUnknownScans() { @Test void looseTaskExposesAllRealMarksAndRejectsMissingDuplicateAndUnknownScans() {
looseTask(); when(applications.selectList(any())).thenReturn(List.of(application)); looseTask(); when(applications.selectList(any())).thenReturn(List.of(application));
var task = service.resolve(identity, "BOX-1", "CD-1"); var task = service.resolve(identity, "BOX-1", "CD-1");
@@ -0,0 +1,43 @@
package com.baiyue.lastleg.discard.repository;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import com.baiyue.lastleg.discard.mapper.CargoDiscardDimensionMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 真实箱内混装SKU按各自件数统计,缺失事实不得用整箱件数替代。 */
class CargoDiscardDimensionRepositoryTest {
private final CargoDiscardDimensionMapper mapper = mock(CargoDiscardDimensionMapper.class);
private final CargoDiscardDimensionRepository repository = new CargoDiscardDimensionRepository();
/** 注入JSON解析和隔离Mapper。 */
CargoDiscardDimensionRepositoryTest() {
ReflectionTestUtils.setField(repository, "dimensions", mapper);
ReflectionTestUtils.setField(repository, "objectMapper", new ObjectMapper());
}
/** 一箱混装A五件和B七件,不能把十二件分别计给两个SKU。 */
@Test void mixedCartonUsesMatchingSkuOnly() {
var line = line(1, "A");
when(mapper.selectedCartonPieces("C", 1L)).thenReturn(List.of("[{\"skuCode\":\"A\",\"quantity\":5},{\"skuCode\":\"B\",\"quantity\":7}]"));
assertEquals(5L, repository.pieces("C", line));
line.setSkuCode("B"); assertEquals(7L, repository.pieces("C", line));
line.setSkuCode("MISSING"); assertNull(repository.pieces("C", line));
}
/** 两箱五件和七件累计十二件,箱数量不足或JSON损坏时保留未知。 */
@Test void everySelectedCartonNeedsKnownPositiveContents() {
var line = line(2, "A");
when(mapper.selectedCartonPieces("C", 1L)).thenReturn(List.of("[{\"skuCode\":\"A\",\"quantity\":5}]", "[{\"skuCode\":\"A\",\"quantity\":7}]"));
assertEquals(12L, repository.pieces("C", line));
when(mapper.selectedCartonPieces("C", 1L)).thenReturn(List.of("[{\"skuCode\":\"A\",\"quantity\":5}]"));
assertNull(repository.pieces("C", line));
when(mapper.selectedCartonPieces("C", 1L)).thenReturn(List.of("[]", "broken"));
assertNull(repository.pieces("C", line));
}
/** 指定申请箱数和当前SKU,不创建库存。 */
private static CargoDiscardItem line(int boxes, String sku) {
var line = new CargoDiscardItem(); line.setId(1L); line.setDiscardPackages(boxes); line.setSkuCode(sku); return line;
}
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.discard.repository;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;
class CargoDiscardQuantityRepositoryTest {
/** 完工前展示申请十箱,完工后费用及列表使用实弃六箱或零箱。 */
@Test void completedSummaryUsesActualWithoutChangingRequestedSnapshot() {
var mapper = mock(CargoDiscardItemMapper.class);
var repository = new CargoDiscardQuantityRepository();
ReflectionTestUtils.setField(repository, "items", mapper);
var application = new CargoDiscardApplication(); application.setCustomerId("C1"); application.setId(1L);
var line = new CargoDiscardItem(); line.setDiscardPackages(10); line.setActualDiscardPackages(6);
when(mapper.listApplication("C1", 1L)).thenReturn(List.of(line));
application.setStatus("APPROVED"); assertEquals(10, repository.boxes(application));
application.setStatus("PDA_COMPLETED"); assertEquals(6, repository.boxes(application));
application.setStatus("COMPLETED"); assertEquals(6, repository.boxes(application));
line.setActualDiscardPackages(0); assertEquals(0, repository.boxes(application));
assertEquals(10, line.getDiscardPackages());
}
}
@@ -40,10 +40,11 @@ class CargoDiscardStatisticsServiceTest {
@Test void unknownPiecesAndLoosePalletNeverBecomeZero() { @Test void unknownPiecesAndLoosePalletNeverBecomeZero() {
when(queries.list("C", query)).thenReturn(List.of(application(1L))); when(queries.list("C", query)).thenReturn(List.of(application(1L)));
when(dimensions.items("C", 1L)).thenReturn(List.of(item(null, "A", 2, 0))); when(dimensions.items("C", 1L)).thenReturn(List.of(item(null, "A", 2, 0)));
when(dimensions.pieces(eq("C"), any())).thenReturn(null);
when(dimensions.pallets("C", 1L)).thenReturn(List.of("")); when(dimensions.pallets("C", 1L)).thenReturn(List.of(""));
var result = service.statistics("C", query); var result = service.statistics("C", query);
assertEquals(2, result.requestedPackages()); assertEquals(0L, result.actualPackages()); assertEquals(2, result.requestedPackages()); assertEquals(0L, result.actualPackages());
assertNull(result.requestedPieces()); assertNull(result.actualPieces()); assertNull(result.palletCount()); assertNull(result.requestedPieces()); assertEquals(0L, result.actualPieces()); assertNull(result.palletCount());
} }
/** 空筛选有零票零申请箱,但不能伪造实际作业或商品、托盘事实。 */ /** 空筛选有零票零申请箱,但不能伪造实际作业或商品、托盘事实。 */
@Test void emptySelectionHasNoInventedActualFacts() { @Test void emptySelectionHasNoInventedActualFacts() {
@@ -53,6 +54,29 @@ class CargoDiscardStatisticsServiceTest {
assertNull(result.actualPackages()); assertNull(result.requestedPieces()); assertNull(result.palletCount()); assertNull(result.actualPackages()); assertNull(result.requestedPieces()); assertNull(result.palletCount());
verifyNoInteractions(dimensions); verifyNoInteractions(dimensions);
} }
/** 两箱实收五件与七件,实际确认后件数仍由箱级事实汇总。 */
@Test void selectedTransferCartonsProvideRealPieces() {
var line = item(null, "TRANSFER-SKU", 2, 2);
when(queries.list("C", query)).thenReturn(List.of(application(1L)));
when(dimensions.items("C", 1L)).thenReturn(List.of(line));
when(dimensions.pieces("C", line)).thenReturn(12L);
when(dimensions.pallets("C", 1L)).thenReturn(List.of("P"));
var result = service.statistics("C", query);
assertEquals(2, result.requestedPackages()); assertEquals(2L, result.actualPackages());
assertEquals(12L, result.requestedPieces()); assertEquals(12L, result.actualPieces());
}
/** 创建一票同客户同仓的隔离单据。 */
@Test void mixedTransferSkuLinesDeduplicatePhysicalBoxesWithoutLosingPieces() {
var a = item(null, "A", 2, 2); var b = item(null, "B", 2, 2);
for (var line : List.of(a, b)) { line.setSourceType("INVENTORY"); line.setInboundCode("ZY-MIX"); line.setCartonMarksJson("[\"BOX-1\",\"BOX-2\"]"); }
when(queries.list("C", query)).thenReturn(List.of(application(1L)));
when(dimensions.items("C", 1L)).thenReturn(List.of(a, b));
when(dimensions.pieces("C", a)).thenReturn(8L); when(dimensions.pieces("C", b)).thenReturn(18L);
when(dimensions.pallets("C", 1L)).thenReturn(List.of("P", "P"));
var result = service.statistics("C", query);
assertEquals(2, result.skuCount()); assertEquals(2, result.requestedPackages()); assertEquals(2L, result.actualPackages());
assertEquals(26L, result.requestedPieces()); assertEquals(26L, result.actualPieces()); assertEquals(1, result.palletCount());
}
/** 创建一票同客户同仓的隔离单据。 */ /** 创建一票同客户同仓的隔离单据。 */
private static CargoDiscardApplication application(long id) { var a = new CargoDiscardApplication(); a.setId(id); a.setCustomerId("C"); a.setOverseasWarehouseId("W"); return a; } private static CargoDiscardApplication application(long id) { var a = new CargoDiscardApplication(); a.setId(id); a.setCustomerId("C"); a.setOverseasWarehouseId("W"); return a; }
/** 明确退货件数和实际值,不引入固定箱规。 */ /** 明确退货件数和实际值,不引入固定箱规。 */
@@ -32,12 +32,14 @@ class CargoDiscardStockServiceTest {
final CargoDiscardApplication application = new CargoDiscardApplication(); final CargoDiscardApplication application = new CargoDiscardApplication();
CargoDiscardRequests.Item request(int count) { return new CargoDiscardRequests.Item(null, null, "SKU", count, 42L); } CargoDiscardRequests.Item request(int count) { return new CargoDiscardRequests.Item(null, null, "SKU", count, 42L); }
@BeforeEach void setup() { @BeforeEach void setup() {
ReflectionTestUtils.setField(service, "stock", stock); var repository = new com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository();
ReflectionTestUtils.setField(service, "positions", positions); ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "balances", balances); ReflectionTestUtils.setField(repository, "stock", stock);
ReflectionTestUtils.setField(repository, "positions", positions);
ReflectionTestUtils.setField(repository, "balances", balances);
ReflectionTestUtils.setField(service, "commands", commands); ReflectionTestUtils.setField(service, "commands", commands);
ReflectionTestUtils.setField(service, "items", items); ReflectionTestUtils.setField(repository, "items", items);
ReflectionTestUtils.setField(service, "links", links); ReflectionTestUtils.setField(repository, "links", links);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
position.setId(42L); position.setCustomerId("C1"); position.setBalanceId(51L); position.setId(42L); position.setCustomerId("C1"); position.setBalanceId(51L);
position.setOverseasWarehouseId("99"); position.setPalletNo("P-42"); position.setAvailablePackages(100L); position.setOverseasWarehouseId("99"); position.setPalletNo("P-42"); position.setAvailablePackages(100L);
@@ -83,6 +85,33 @@ class CargoDiscardStockServiceTest {
var carton = new com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow(); var carton = new com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow();
carton.setSourceType("INBOUND"); carton.setId(id); carton.setCartonMark(mark); carton.setSkuCode("SKU"); return carton; carton.setSourceType("INBOUND"); carton.setId(id); carton.setCartonMark(mark); carton.setSkuCode("SKU"); return carton;
} }
/** 同一混装实箱的两个 SKU 保留完整快照,物理仓位只冻结一次,实箱只锁定和保存一次,漏 SKU 时不得产生任何冻结。 */
@Test void mixedTransferCartonRequiresAllSkusAndLocksPhysicalBoxOnce() {
position.setAvailablePackages(1L); balance.setAvailablePackages(1L);
var box = carton(1L, "MIXED-1"); box.setSourceType("TRANSFER");
box.setSkuItemsJson("[{\"skuCode\":\"SKU\",\"quantity\":5},{\"skuCode\":\"SECOND\",\"quantity\":7}]");
when(stock.genericCartons("C1", 42L)).thenReturn(List.of(box));
when(stock.changeTransferCartonStatus("C1", 1L, "COUNTED", "DISCARD_PENDING")).thenReturn(1);
var a = new CargoDiscardRequests.Item(null, null, "SKU", 1, 42L, List.of(1L));
var b = new CargoDiscardRequests.Item(null, null, "SECOND", 1, 42L, List.of(1L));
assertThrows(ResponseStatusException.class, () -> service.reserveAll("C1", "OP", application, List.of(a), LocalDateTime.now()));
verifyNoInteractions(commands, items, links);
service.reserveAll("C1", "OP", application, List.of(a, b), LocalDateTime.now());
verify(stock, times(1)).changeTransferCartonStatus("C1", 1L, "COUNTED", "DISCARD_PENDING");
verify(links, times(1)).insert(any(CargoDiscardCarton.class));
verify(commands, times(1)).hold(eq("C1"), eq("OP"), any());
var saved = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items, times(2)).insert(saved.capture());
assertTrue(saved.getAllValues().stream().allMatch(i -> service.cartonMarks(i).equals(List.of("MIXED-1"))));
assertEquals(1, com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(saved.getAllValues(), false));
when(items.listApplication("C1", 11L)).thenReturn(saved.getAllValues());
var physical = ArgumentCaptor.forClass(CargoDiscardCarton.class); verify(links).insert(physical.capture());
when(links.listApplication("C1", 11L)).thenReturn(List.of(physical.getValue()));
when(stock.changeTransferCartonStatus("C1", 1L, "DISCARD_PENDING", "DISCARDED")).thenReturn(1);
for (var line : saved.getAllValues()) { service.discard("C1", "OP", application, line, 1); line.setActualDiscardPackages(1); }
verify(stock, times(1)).changeTransferCartonStatus("C1", 1L, "DISCARD_PENDING", "DISCARDED");
verify(commands, times(1)).discardHeld(eq("C1"), eq("OP"), any());
assertEquals(1, com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(saved.getAllValues(), true));
}
@Test void partialDisposalFreezesExactlySelectedBoxesAndLeavesTheOthersUntouched() { @Test void partialDisposalFreezesExactlySelectedBoxesAndLeavesTheOthersUntouched() {
position.setAvailablePackages(3L); balance.setAvailablePackages(3L); position.setAvailablePackages(3L); balance.setAvailablePackages(3L);
when(stock.genericCartons("C1", 42L)).thenReturn(List.of(carton(1L, "BOX-1"), carton(2L, "BOX-2"), carton(3L, "BOX-3"))); when(stock.genericCartons("C1", 42L)).thenReturn(List.of(carton(1L, "BOX-1"), carton(2L, "BOX-2"), carton(3L, "BOX-3")));
@@ -133,4 +162,57 @@ class CargoDiscardStockServiceTest {
verify(stock, never()).changeGenericCartonStatus(anyString(), anyLong(), anyString(), anyString()); verify(stock, never()).changeGenericCartonStatus(anyString(), anyLong(), anyString(), anyString());
} }
/** 即使有同数量箱记录,缺失箱唛时仍应展示相关托盘并允许仓库实点。 */
@Test void incompleteCartonMarksUseActualQuantityWithoutGuessingBoxes() {
position.setAvailablePackages(1L); balance.setAvailablePackages(1L);
when(stock.genericPositions("C1", "WT")).thenReturn(List.of(position));
when(stock.genericCartons("C1", 42L)).thenReturn(List.of(carton(1L, null)));
var candidate = service.candidates("C1", "WT").get(0);
assertFalse(candidate.cartonSelectionSupported()); assertEquals("P-42", candidate.palletNo());
service.reserve("C1", "OP", application, request(1), LocalDateTime.now());
var saved = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(saved.capture());
assertTrue(service.actualQuantityEditable(saved.getValue()));
assertTrue(service.cartonMarks(saved.getValue()).isEmpty());
verifyNoInteractions(links);
verify(stock, never()).changeGenericCartonStatus(anyString(), anyLong(), anyString(), anyString());
}
/** 预留十箱、实弃六箱,扣减与释放分别使用稳定幂等键。 */
@Test void unboundPalletDiscardsActualAndReleasesUnfinishedReservation() {
var line = new CargoDiscardItem(); line.setId(71L); line.setSourceType("INVENTORY");
line.setInventoryPositionId(42L); line.setDiscardPackages(10);
assertTrue(service.actualQuantityEditable(line));
service.discard("C1", "OP", application, line, 6);
var deduction = ArgumentCaptor.forClass(InventoryCommandRequests.Move.class);
var release = ArgumentCaptor.forClass(InventoryCommandRequests.Move.class);
verify(commands).discardHeld(eq("C1"), eq("OP"), deduction.capture());
verify(commands).release(eq("C1"), eq("OP"), release.capture());
assertEquals(6, deduction.getValue().packages()); assertEquals(4, release.getValue().packages());
assertEquals(deduction.getValue().sourceEventId(), release.getValue().sourceEventId());
assertNotEquals(deduction.getValue().idempotencyKey(), release.getValue().idempotencyKey());
verifyNoInteractions(stock, links);
}
/** 实际未废弃时只释放全部预留,不产生零数量扣减。 */
@Test void unboundPalletZeroDisposalReleasesAllHeldStock() {
var line = new CargoDiscardItem(); line.setId(71L); line.setSourceType("INVENTORY");
line.setInventoryPositionId(42L); line.setDiscardPackages(10);
service.discard("C1", "OP", application, line, 0);
verify(commands, never()).discardHeld(anyString(), anyString(), any());
var release = ArgumentCaptor.forClass(InventoryCommandRequests.Move.class);
verify(commands).release(eq("C1"), eq("OP"), release.capture());
assertEquals(10, release.getValue().packages());
}
/** 已选具体箱子不能通过实际数量绕过核对,超量和负数同样不得写库存。 */
@Test void exactBoxesAndInvalidActualsCannotUseQuantityOnlyCompletion() {
var line = new CargoDiscardItem(); line.setId(71L); line.setSourceType("INVENTORY");
line.setInventoryPositionId(42L); line.setDiscardPackages(2);
assertThrows(ResponseStatusException.class, () -> service.discard("C1", "OP", application, line, -1));
assertThrows(ResponseStatusException.class, () -> service.discard("C1", "OP", application, line, 3));
line.setCartonMarksJson("[\"BOX-1\",\"BOX-2\"]");
assertFalse(service.actualQuantityEditable(line));
assertThrows(ResponseStatusException.class, () -> service.discard("C1", "OP", application, line, 1));
verifyNoInteractions(commands, stock, links);
}
} }
@@ -140,7 +140,7 @@ public class DispatchAggregatePersistenceTest {
/** 真实司机Service继续执行当前/历史附件关联检查,不由排车测试替身伪装通过。 */ /** 真实司机Service继续执行当前/历史附件关联检查,不由排车测试替身伪装通过。 */
DriverEvidenceFixture() throws Exception { DriverEvidenceFixture() throws Exception {
f=new Fixture();driver.setId(71L);driver.setLicenseFileId(81L);when(driverRows.selectById(71L)).thenReturn(driver); f=new Fixture();driver.setId(71L);driver.setLicenseFileId(81L);when(driverRows.selectById(71L)).thenReturn(driver);
var drivers=new DriverService(driverRows,history,files,f.json,Clock.systemUTC(),mock(FleetWarehouseDirectory.class)); var drivers=new DriverService(driverRows,history,files,f.json,mock(FleetWarehouseDirectory.class));
org.springframework.test.util.ReflectionTestUtils.setField(f.fleet,"drivers",drivers); org.springframework.test.util.ReflectionTestUtils.setField(f.fleet,"drivers",drivers);
dispatchFiles=(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.service,"evidence"); dispatchFiles=(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.service,"evidence");
} }
@@ -1,5 +1,7 @@
package com.baiyue.lastleg.dispatch.service; package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.port.DispatchSourceAllocation;
import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
@@ -326,7 +328,7 @@ class DispatchItemSourcePersistenceTest {
capture(); capture();
var allocated = allocate(first, 101, 3, 10, null, 6); var allocated = allocate(first, 101, 3, 10, null, 6);
assertEquals(2, allocated.size()); assertEquals(2, allocated.size());
assertEquals(3, allocated.stream().mapToLong(DispatchItemSourceService.Allocation::packages).sum()); assertEquals(3, allocated.stream().mapToLong(DispatchSourceAllocation::packages).sum());
assertEquals(10, allocated.stream().filter(a -> a.productQuantity() != null).mapToLong(a -> a.productQuantity()).sum()); assertEquals(10, allocated.stream().filter(a -> a.productQuantity() != null).mapToLong(a -> a.productQuantity()).sum());
assertEquals(1, allocated.stream().filter(a -> a.productQuantity() == null).count()); assertEquals(1, allocated.stream().filter(a -> a.productQuantity() == null).count());
assertThrows(BizException.class, () -> allocate(first, 101, 3, 20, null, 6)); assertThrows(BizException.class, () -> allocate(first, 101, 3, 20, null, 6));
@@ -368,7 +370,7 @@ class DispatchItemSourcePersistenceTest {
} }
/** 本次输入件数明确传入,库存数量取调用方锁后的受控真实位置快照。 */ /** 本次输入件数明确传入,库存数量取调用方锁后的受控真实位置快照。 */
private List<DispatchItemSourceService.Allocation> allocate(DispatchItem item, long position, long packages, private List<DispatchSourceAllocation> allocate(DispatchItem item, long position, long packages,
long quantity, String mark, long hold) { long quantity, String mark, long hold) {
long balance = position == 102 ? 602 : 601; long balance = position == 102 ? 602 : 601;
return transaction.execute(status -> service.allocate(DISPATCH, item, position, packages, quantity, mark, return transaction.execute(status -> service.allocate(DISPATCH, item, position, packages, quantity, mark,
@@ -376,7 +378,7 @@ class DispatchItemSourcePersistenceTest {
} }
/** 模拟已提交的不可变真实拣货账本供实际 Mapper 读取消费额度,不冒充 PDA 全链。 */ /** 模拟已提交的不可变真实拣货账本供实际 Mapper 读取消费额度,不冒充 PDA 全链。 */
private void pick(long id, String customer, long fulfillment, DispatchItemSourceService.Allocation allocation) { private void pick(long id, String customer, long fulfillment, DispatchSourceAllocation allocation) {
jdbc.update(""" jdbc.update("""
INSERT INTO ll_dispatch_pda_pick_order INSERT INTO ll_dispatch_pda_pick_order
(id,customer_id,pick_id,dispatch_item_source_id,order_id,packages,product_quantity,created_at) (id,customer_id,pick_id,dispatch_item_source_id,order_id,packages,product_quantity,created_at)
@@ -10,6 +10,7 @@ import static org.assertj.core.api.Assertions.*;
class FleetPickupMapperTest { class FleetPickupMapperTest {
JdbcTemplate db;FleetPickupMapper mapper; JdbcTemplate db;FleetPickupMapper mapper;
/** 每个用例建立独立数据库,通过真实 Mapper 验证取货只读来源投影。 */
@BeforeEach void setup() throws Exception { @BeforeEach void setup() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:pickup_"+System.nanoTime()+";MODE=MySQL;DB_CLOSE_DELAY=-1");db=new JdbcTemplate(ds); var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:pickup_"+System.nanoTime()+";MODE=MySQL;DB_CLOSE_DELAY=-1");db=new JdbcTemplate(ds);
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),transfer_no VARCHAR(64),inbound_code VARCHAR(64),lastleg_order_no VARCHAR(64),operating_warehouse_id VARCHAR(64),delivery_address VARCHAR(500),pickup_contact_name VARCHAR(100),pickup_contact_phone VARCHAR(40),package_count INT,estimated_arrival_at TIMESTAMP,status VARCHAR(32),delivery_method VARCHAR(40),created_at TIMESTAMP)"); db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),transfer_no VARCHAR(64),inbound_code VARCHAR(64),lastleg_order_no VARCHAR(64),operating_warehouse_id VARCHAR(64),delivery_address VARCHAR(500),pickup_contact_name VARCHAR(100),pickup_contact_phone VARCHAR(40),package_count INT,estimated_arrival_at TIMESTAMP,status VARCHAR(32),delivery_method VARCHAR(40),created_at TIMESTAMP)");
@@ -23,6 +24,26 @@ class FleetPickupMapperTest {
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetPickupMapper.class); mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetPickupMapper.class);
} }
/** 释放隔离库,避免完整回归累积 H2 内存。 */
@AfterEach void close() { if(db!=null)db.execute("SHUTDOWN"); }
/** 显式列投影保留车队全部资格和展示字段,不依赖转运实体映射。 */
@Test void sourceProjectionPreservesPickupFactsAndCustomerScope() {
var source=mapper.lockSource("C1","WT-1");
assertThat(source.getId()).isEqualTo(1L);
assertThat(source.getCustomerId()).isEqualTo("C1");
assertThat(source.getTransferNo()).isEqualTo("WT-1");
assertThat(source.getInboundCode()).isEqualTo("WT-1");
assertThat(source.getLastlegOrderNo()).isEqualTo("LL-1");
assertThat(source.getOperatingWarehouseId()).isEqualTo("100");
assertThat(source.getDeliveryAddress()).isEqualTo("Ontario Test Address");
assertThat(source.getPickupContactName()).isEqualTo("Contact");
assertThat(source.getPickupContactPhone()).isEqualTo("0000000000");
assertThat(source.getPackageCount()).isEqualTo(5);
assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING");
assertThat(source.getDeliveryMethod()).isEqualTo("WAREHOUSE_PICKUP");
assertThat(mapper.lockSource("C2","WT-1")).isNull();
assertThat(mapper.source("C1","WT-1").getId()).isEqualTo(source.getId());
}
@Test void candidateQualificationPaginationAndCustomerScopeUseActualSql() { @Test void candidateQualificationPaginationAndCustomerScopeUseActualSql() {
assertThat(mapper.countCandidates(null)).isEqualTo(2); assertThat(mapper.countCandidates(null)).isEqualTo(2);
assertThat(mapper.candidates(null,1,0)).extracting(FleetPickupModels.Candidate::transferNo).containsExactly("WT-1"); assertThat(mapper.candidates(null,1,0)).extracting(FleetPickupModels.Candidate::transferNo).containsExactly("WT-1");
@@ -4,7 +4,6 @@ import com.baiyue.lastleg.fleet.driver.*;
import com.baiyue.lastleg.fleet.reporting.*; import com.baiyue.lastleg.fleet.reporting.*;
import com.baiyue.lastleg.fleet.trip.*; import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*; import org.junit.jupiter.api.*;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -16,7 +15,7 @@ import static org.mockito.ArgumentMatchers.*;
class FleetPickupServiceTest { class FleetPickupServiceTest {
FleetPickupRepository repository;FleetTripService trips;FleetPickupService service; FleetPickupRepository repository;FleetTripService trips;FleetPickupService service;
TripOperationsEvidenceGateway files;FleetTripEntity trip;WarehouseTransferOrder source;FleetPickupTask task; TripOperationsEvidenceGateway files;FleetTripEntity trip;FleetPickupSource source;FleetPickupTask task;
final Map<String,String> commands=new HashMap<>(); final Map<String,String> commands=new HashMap<>();
@BeforeEach void setup() { @BeforeEach void setup() {
repository=mock(FleetPickupRepository.class);trips=mock(FleetTripService.class);files=mock(TripOperationsEvidenceGateway.class); repository=mock(FleetPickupRepository.class);trips=mock(FleetTripService.class);files=mock(TripOperationsEvidenceGateway.class);
@@ -30,15 +29,18 @@ class FleetPickupServiceTest {
doAnswer(c->{commands.put(c.getArgument(1),c.getArgument(2));return null;}).when(repository).command(anyString(),anyString(),anyString()); doAnswer(c->{commands.put(c.getArgument(1),c.getArgument(2));return null;}).when(repository).command(anyString(),anyString(),anyString());
doAnswer(c->{trip.setRecordVersion(trip.getRecordVersion()+1);return null;}).when(trips).recordExecution(any(),anyLong(),anyString(),anyString(),anyString()); doAnswer(c->{trip.setRecordVersion(trip.getRecordVersion()+1);return null;}).when(trips).recordExecution(any(),anyLong(),anyString(),anyString(),anyString());
} }
@Test void pickupMustArriveBeforePdaAndWrongWarehouseCannotRead() { /** 原单所属域锁定来源后,车队仅以该来源核对真实送仓事实。 */
when(repository.pdaSource("100","WT-1")).thenReturn(source); @Test void pickupMustArriveBeforePda() {
assertThatThrownBy(()->service.requireMeasuringSource("100","WT-1")).hasMessageContaining("先完成车辆取货送仓"); assertThatThrownBy(()->service.requireArrived(1L,"WAREHOUSE_PICKUP","WT-1","WT-1")).hasMessageContaining("先完成车辆取货送仓");
task.setStatus("ARRIVED");assertThat(service.requireMeasuringSource("100","WT-1")).isSameAs(source); task.setStatus("ARRIVED");
assertThatThrownBy(()->service.requireMeasuringSource("200","WT-1")).isInstanceOf(ResponseStatusException.class); assertThatCode(()->service.requireArrived(1L,"WAREHOUSE_PICKUP","WT-1","WT-1")).doesNotThrowAnyException();
assertThatThrownBy(()->service.requireArrived(2L,"WAREHOUSE_PICKUP","WT-2","WT-2")).hasMessageContaining("先完成车辆取货送仓");
} }
/** 客户自送和已有库存转运沿本域原测量资格,不要求不存在的取货任务。 */
@Test void selfDeliveryKeepsOriginalMeasurementPath() { @Test void selfDeliveryKeepsOriginalMeasurementPath() {
source.setDeliveryMethod("CUSTOMER_DELIVERY");when(repository.pdaSource("100","WT-1")).thenReturn(source); assertThatCode(()->service.requireArrived(1L,"CUSTOMER_DELIVERY","WT-1","WT-1")).doesNotThrowAnyException();
assertThat(service.requireMeasuringSource("100","WT-1")).isSameAs(source);verify(repository,never()).task(anyLong()); assertThatCode(()->service.requireArrived(1L,"WAREHOUSE_PICKUP","WT-1","ORIGINAL-INBOUND")).doesNotThrowAnyException();
verify(repository,never()).task(anyLong());
} }
@Test void cannotScheduleSelfDeliveryOrAnotherWarehouseOrDuplicateSource() { @Test void cannotScheduleSelfDeliveryOrAnotherWarehouseOrDuplicateSource() {
var plan=new FleetPickupModels.Create(List.of(new FleetPickupModels.Source("C1","WT-1")),Instant.now(),Instant.now().plusSeconds(7200),null,"create-key-1"); var plan=new FleetPickupModels.Create(List.of(new FleetPickupModels.Source("C1","WT-1")),Instant.now(),Instant.now().plusSeconds(7200),null,"create-key-1");
@@ -98,8 +100,8 @@ class FleetPickupServiceTest {
verify(trips,never()).recordExecution(any(),anyLong(),anyString(),anyString(),anyString()); verify(trips,never()).recordExecution(any(),anyLong(),anyString(),anyString(),anyString());
} }
/** 构造有业务含义的原转运单,箱唛与库存由原链路持有。 */ /** 构造有业务含义的原转运单,箱唛与库存由原链路持有。 */
static WarehouseTransferOrder source(long id,String customer,String number,String warehouse) { static FleetPickupSource source(long id,String customer,String number,String warehouse) {
var row=new WarehouseTransferOrder();row.setId(id);row.setCustomerId(customer);row.setTransferNo(number);row.setInboundCode(number); var row=new FleetPickupSource();row.setId(id);row.setCustomerId(customer);row.setTransferNo(number);row.setInboundCode(number);
row.setOperatingWarehouseId(warehouse);row.setDeliveryMethod("WAREHOUSE_PICKUP");row.setStatus("WAIT_PDA_RECEIVING");row.setPackageCount(5); row.setOperatingWarehouseId(warehouse);row.setDeliveryMethod("WAREHOUSE_PICKUP");row.setStatus("WAIT_PDA_RECEIVING");row.setPackageCount(5);
row.setDeliveryAddress("1580 S Grove Ave, Ontario, CA 91761");row.setPickupContactName("取货测试联系人");row.setPickupContactPhone("0000000000");return row; row.setDeliveryAddress("1580 S Grove Ave, Ontario, CA 91761");row.setPickupContactName("取货测试联系人");row.setPickupContactPhone("0000000000");return row;
} }
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.inventory.query;
import com.baiyue.lastleg.inventory.query.mapper.InventoryPieceMapper.PieceRow;
import com.baiyue.lastleg.inventory.query.repository.InventoryPieceRepository;
import com.baiyue.lastleg.inventory.query.service.InventoryPieceService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class InventoryPieceServiceTest {
/** 混箱全部SKU与数字字符串正确投影,多组共享物理托盘只累计一次。 */
@Test void projectsAllSkusWithoutMultiplyingCartonsOrPallets() {
var repository=mock(InventoryPieceRepository.class);
when(repository.rows("A",null)).thenReturn(List.of(
row(1,10,100,2,101,10L,"[{\"skuCode\":\"A\",\"quantity\":3},{\"skuCode\":\"B\",\"quantity\":\"7\"}]"),
row(1,10,100,2,102,2L,"[{\"skuCode\":\"A\",\"quantity\":2}]"),
row(2,11,100,1,103,4L,"[{\"skuCode\":\"C\",\"quantity\":4}]")));
var result=service(repository).project("A",null);
assertEquals(16L,result.availablePieceCount()); assertEquals(3,result.availableCartonCount()); assertEquals(1,result.availablePalletCount());
assertEquals(2,result.groups().size()); assertEquals(2,result.groups().get(0).skuContents().size());
assertEquals(7,result.groups().get(0).skuContents().get(1).quantity());
}
/** 箱映射不全、JSON缺失或件数冲突均保留未知,不拿已知部分冒充总数。 */
@Test void incompleteAndInconsistentPositionsRemainUnknown() {
for (var rows:List.of(
List.of(row(1,10,100,2,101,10L,"[{\"skuCode\":\"A\",\"quantity\":10}]")),
List.of(row(1,10,100,1,101,10L,null)),
List.of(row(1,10,100,1,101,10L,"[{\"skuCode\":\"A\",\"quantity\":9}]")))) {
var repository=mock(InventoryPieceRepository.class); when(repository.rows("A",null)).thenReturn(rows);
var result=service(repository).project("A",null);
assertNull(result.availablePieceCount()); assertEquals(0,result.knownAvailablePieceCount());
assertEquals(rows.get(0).availablePackages(),result.unknownCartonCount()); assertTrue(result.groups().get(0).skuContents().isEmpty());
}
}
/** 手工注入只读仓储及JSON解析器,测试不依赖运行服务。 */
private InventoryPieceService service(InventoryPieceRepository repository) {
var service=new InventoryPieceService(); ReflectionTestUtils.setField(service,"repository",repository);
ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper()); return service;
}
/** 保留每个仓位所属记账分组及真实箱身份。 */
private PieceRow row(long position,long balance,long pallet,long boxes,long carton,Long quantity,String json) {
return new PieceRow(position,balance,"A","W","DEST",balance==10?"A":"C",pallet,boxes,carton,"BOX-"+carton,quantity,json);
}
}
@@ -0,0 +1,54 @@
package com.baiyue.lastleg.palletops.service;
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.*;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
class PalletMutationOrderMapperTest {
/** 执行真实Mapper,证明入仓ID不同于尾程ID且同源分片跨客户不会串锁。 */
@Test void resolvesSnapshotIdsToRealOrdersAndKeepsMissingSourcesFailClosed() {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var db=new JdbcTemplate(ds);
try {
db.execute("CREATE TABLE ll_relabel_application(id BIGINT,customer_id VARCHAR,relabel_no VARCHAR,lastleg_order_id BIGINT)");
db.execute("CREATE TABLE ll_relabel_item(customer_id VARCHAR,application_id BIGINT,source_inbound_order_id BIGINT,return_carton_id BIGINT)");
db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR,source_inbound_id VARCHAR)");
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,source_inbound_id VARCHAR)");
db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR,balance_id BIGINT,overseas_warehouse_id VARCHAR,pallet_id BIGINT)");
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR,inbound_order_id BIGINT)");
db.execute("CREATE TABLE ll_inbound_stock_receipt(customer_id VARCHAR,order_id BIGINT,inventory_position_id BIGINT,warehouse_pallet_id BIGINT)");
db.execute("CREATE TABLE ll_return_stock_receipt(customer_id VARCHAR,order_id BIGINT,inventory_position_id BIGINT)");
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR,warehouse_pallet_id BIGINT)");
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,overseas_warehouse_id VARCHAR)");
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,plan_id BIGINT,customer_id VARCHAR,line_id BIGINT)");
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,customer_id VARCHAR,order_id BIGINT)");
db.execute("CREATE TABLE ll_return_inventory_carton(id BIGINT,customer_id VARCHAR,source_return_no VARCHAR,overseas_warehouse_id VARCHAR,total_quantity BIGINT,pallet_code VARCHAR)");
db.execute("CREATE TABLE ll_return_order(customer_id VARCHAR,return_no VARCHAR,lastleg_order_id BIGINT)");
db.update("INSERT INTO ll_relabel_application VALUES(1,'C','RL',900)");
db.update("INSERT INTO ll_relabel_item VALUES('C',1,100,NULL)");
db.update("INSERT INTO ll_inbound_order VALUES(100,'C','WAREHOUSE_TRANSFER:ZY'),(101,'C','MISSING')");
db.update("INSERT INTO ll_order VALUES(200,'C','WAREHOUSE_TRANSFER:ZY'),(201,'OTHER','WAREHOUSE_TRANSFER:ZY')");
db.update("INSERT INTO ll_inventory_balance VALUES(10,'C',100),(11,'C',101)");
db.update("INSERT INTO ll_inventory_position VALUES(20,'C',10,'WH',500),(21,'C',11,'WH',501)");
var configuration=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds));
configuration.addMapper(PdaPalletOperationMapper.class);
try(var session=new SqlSessionFactoryBuilder().build(configuration).openSession()){
var mapper=session.getMapper(PdaPalletOperationMapper.class);
var rows=mapper.mutationOrders("WH",List.of(500L),List.of("P500"),List.of("RL"));
assertThat(rows).extracting(PdaPalletOperationMapper.MutationOrder::orderId).containsExactlyInAnyOrder(900L,200L);
assertThat(rows).allSatisfy(r->assertThat(r.customerId()).isEqualTo("C"));
assertThat(mapper.mutationOrders("WH",List.of(501L),List.of("P501"),List.of())).singleElement().satisfies(r->{assertThat(r.customerId()).isEqualTo("C");assertThat(r.orderId()).isNull();});
}
} finally {
// Mapper会话先关闭,再销毁本用例独有的H2数据库,不把内存库留给全量回归。
db.execute("SHUTDOWN");
}
}
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.palletops.service;
import com.baiyue.lastleg.palletops.repository.PalletOperationRecoveryRepository;
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static org.junit.jupiter.api.Assertions.*;
class PalletOperationRecoveryServiceTest {
private final PalletOperationRecoveryRepository repository=mock(PalletOperationRecoveryRepository.class);
private final ObjectMapper json=new ObjectMapper().findAndRegisterModules();
private final PalletOperationRecoveryService service=new PalletOperationRecoveryService(repository,json);
private PdaPalletOperation row(){var r=new PdaPalletOperation();r.setId(9L);r.setOverseasWarehouseId(88L);r.setOperationType("RELABEL_PALLET_DISASSEMBLE");r.setStatus("PROCESSING");r.setUpdatedAt(LocalDateTime.now().minusHours(1));r.setRequestHash("original-hash");return r;}
private PalletOperationRecoveryService.Close request(PdaPalletOperation r){return new PalletOperationRecoveryService.Close(88L,r.getUpdatedAt(),"recovery-1","核实无远端意图且无业务提交");}
@Test void closesOnlyNoEffectAndReplaysImmutableAudit() throws Exception {
var row=row();row.setLastError("original failure");when(repository.lock(9L)).thenReturn(row);when(repository.close(eq(9L),anyString(),any())).thenReturn(true);
var result=service.close(9L,"admin",request(row));assertEquals("RECOVERED_NO_EFFECT",result.status());assertEquals("original failure",result.previousError());assertFalse(result.replayed());
row.setStatus(result.status());row.setResultJson(json.writeValueAsString(result));
var replay=service.close(9L,"admin",request(row));assertTrue(replay.replayed());assertEquals(result.closedAt(),replay.closedAt());verify(repository,times(1)).close(eq(9L),anyString(),any());assertEquals("original-hash",row.getRequestHash());
assertThrows(ResponseStatusException.class,()->service.close(9L,"different-admin",request(row)));
}
@Test void unknownOrConfirmedRemoteAttemptsCannotBeClosed(){for(String journal:new String[]{"[{\"operationKey\":\"x\",\"receipt\":null}]","[{\"receipt\":{\"operationStatus\":\"APPLIED\"}}]","{}","corrupt"}){var row=row();row.setRemoteTransitionsJson(journal);when(repository.lock(9L)).thenReturn(row);assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));}verify(repository,never()).close(anyLong(),anyString(),any());}
@Test void activeChangedCompletedAndWrongWarehouseReject(){var row=row();when(repository.lock(9L)).thenReturn(row);var old=request(row);row.setUpdatedAt(LocalDateTime.now());assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",old));assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));row.setStatus("COMPLETED");assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));row.setStatus("PROCESSING");row.setOverseasWarehouseId(99L);assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));verify(repository,never()).close(anyLong(),anyString(),any());}
@Test void conditionalWriteLossIsNotSuccess(){var row=row();when(repository.lock(9L)).thenReturn(row);when(repository.close(eq(9L),anyString(),any())).thenReturn(false);assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));}
/** 已补偿失败也须逐条确认无意图,审计保留真实前态。 */
@Test void failedCompensatedRequiresTheSameNoRemoteProof(){var row=row();row.setStatus("FAILED_COMPENSATED");when(repository.lock(9L)).thenReturn(row);when(repository.close(eq(9L),anyString(),any())).thenReturn(true);assertEquals("FAILED_COMPENSATED",service.close(9L,"admin",request(row)).previousStatus());row.setRemoteTransitionsJson("[{\"receipt\":null}]");assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));}
}
@@ -19,7 +19,9 @@ import com.baiyue.lastleg.palletops.dto.PalletOperationRequests;
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper; import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
import com.baiyue.lastleg.palletops.mapper.PalletOperationRecoveryMapper;
import com.baiyue.lastleg.palletops.repository.PdaPalletOperationRepository; import com.baiyue.lastleg.palletops.repository.PdaPalletOperationRepository;
import com.baiyue.lastleg.palletops.repository.PalletOperationRecoveryRepository;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory; import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory;
import com.baiyue.lastleg.pda.warehouse.mapper.PdaWarehouseMapper; import com.baiyue.lastleg.pda.warehouse.mapper.PdaWarehouseMapper;
@@ -36,6 +38,7 @@ import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.AfterEach;
import org.mybatis.spring.SqlSessionTemplate; import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.jdbc.datasource.DataSourceTransactionManager;
@@ -65,6 +68,13 @@ import static org.mockito.Mockito.*;
/** 实际MyBatis、H2和Spring事务证明仓内调用端与运输使用同一组成冻结边界。 */ /** 实际MyBatis、H2和Spring事务证明仓内调用端与运输使用同一组成冻结边界。 */
class PdaPalletDispatchFreezePersistenceTest { class PdaPalletDispatchFreezePersistenceTest {
private final List<Fixture> fixtures = new ArrayList<>();
/** 每个用例结束都销毁其独立H2库,避免DB_CLOSE_DELAY=-1在全套测试中累计保留。 */
@AfterEach void closeFixtures() {
for (var fixture : fixtures) fixture.db.execute("SHUTDOWN");
fixtures.clear();
}
private static final LocalDateTime AT = LocalDateTime.of(2026,10,19,12,0); private static final LocalDateTime AT = LocalDateTime.of(2026,10,19,12,0);
private static final PdaWarehouseIdentity ACTOR = new PdaWarehouseIdentity("7","picker",88L); private static final PdaWarehouseIdentity ACTOR = new PdaWarehouseIdentity("7","picker",88L);
@@ -343,7 +353,7 @@ class PdaPalletDispatchFreezePersistenceTest {
} }
/** 隔离数据库保留实际Mapper、事务和共享串行提供者,仅远端资源使用受控替身。 */ /** 隔离数据库保留实际Mapper、事务和共享串行提供者,仅远端资源使用受控替身。 */
static class Fixture { class Fixture {
final JdbcTemplate db; final JdbcTemplate db;
final PdaPalletOperationService service = new PdaPalletOperationService(); final PdaPalletOperationService service = new PdaPalletOperationService();
final PalletPhysicalGateway physical = mock(PalletPhysicalGateway.class); final PalletPhysicalGateway physical = mock(PalletPhysicalGateway.class);
@@ -366,9 +376,12 @@ class PdaPalletDispatchFreezePersistenceTest {
} }
}; };
db = new JdbcTemplate(ds); db = new JdbcTemplate(ds);
// SqlSessionTemplate每次调用自行释放会话;用例最后显式关闭仅本夹具拥有的数据库。
fixtures.add(this);
baseline = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")); baseline = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"));
for (var type : List.of(RelabelApplication.class,RelabelItem.class,ReturnInventoryCarton.class,InventoryPosition.class,InventoryBalance.class,InventoryLedger.class,PdaPalletOperation.class,PdaRelabelTask.class,PdaRelabelHistory.class,PdaOperationHistory.class)) createEntity(type); for (var type : List.of(RelabelApplication.class,RelabelItem.class,ReturnInventoryCarton.class,InventoryPosition.class,InventoryBalance.class,InventoryLedger.class,PdaPalletOperation.class,PdaRelabelTask.class,PdaRelabelHistory.class,PdaOperationHistory.class)) createEntity(type);
createColumns("ll_order",List.of("id","customer_id","detained")); createColumns("ll_order",List.of("id","customer_id","detained","source_inbound_id"));
createColumns("ll_inbound_order",List.of("id","customer_id","source_inbound_id"));
createColumns("ll_inbound_stock_receipt",List.of("id","customer_id","order_id","warehouse_pallet_id","inventory_position_id")); createColumns("ll_inbound_stock_receipt",List.of("id","customer_id","order_id","warehouse_pallet_id","inventory_position_id"));
createColumns("ll_return_stock_receipt",List.of("id","customer_id","order_id","inventory_position_id")); createColumns("ll_return_stock_receipt",List.of("id","customer_id","order_id","inventory_position_id"));
createColumns("ll_inbound_plan",List.of("id","customer_id","overseas_warehouse_id")); createColumns("ll_inbound_plan",List.of("id","customer_id","overseas_warehouse_id"));
@@ -381,6 +394,7 @@ class PdaPalletDispatchFreezePersistenceTest {
createColumns("ll_physical_pallet_guard",List.of("warehouse_pallet_id","created_at")); createColumns("ll_physical_pallet_guard",List.of("warehouse_pallet_id","created_at"));
createColumns("ll_warehouse_transfer_inventory_allocation",List.of("customer_id","inventory_position_id","transfer_order_id","package_count","status")); createColumns("ll_warehouse_transfer_inventory_allocation",List.of("customer_id","inventory_position_id","transfer_order_id","package_count","status"));
var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true);
configuration.addMapper(PalletOperationRecoveryMapper.class);
for (var type : List.of(PdaPalletOperationMapper.class,PhysicalPalletSerialMapper.class,RelabelApplicationMapper.class,RelabelItemMapper.class,ReturnInventoryCartonMapper.class,PdaRelabelTaskMapper.class,PdaRelabelHistoryMapper.class,InventoryPositionMapper.class,InventoryBalanceMapper.class,InventoryLedgerMapper.class,InventoryCommandMapper.class,PdaWarehouseMapper.class)) configuration.addMapper(type); for (var type : List.of(PdaPalletOperationMapper.class,PhysicalPalletSerialMapper.class,RelabelApplicationMapper.class,RelabelItemMapper.class,ReturnInventoryCartonMapper.class,PdaRelabelTaskMapper.class,PdaRelabelHistoryMapper.class,InventoryPositionMapper.class,InventoryBalanceMapper.class,InventoryLedgerMapper.class,InventoryCommandMapper.class,PdaWarehouseMapper.class)) configuration.addMapper(type);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(configuration); var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(configuration);
var sessions = new SqlSessionTemplate(Objects.requireNonNull(factory.getObject())); var sessions = new SqlSessionTemplate(Objects.requireNonNull(factory.getObject()));
@@ -401,6 +415,7 @@ class PdaPalletDispatchFreezePersistenceTest {
operationHistory = spy(sessions.getMapper(PdaWarehouseMapper.class)); operationHistory = spy(sessions.getMapper(PdaWarehouseMapper.class));
var historyService = new PdaWarehouseService(); ReflectionTestUtils.setField(historyService,"mapper",operationHistory); var historyService = new PdaWarehouseService(); ReflectionTestUtils.setField(historyService,"mapper",operationHistory);
ReflectionTestUtils.setField(service,"mapper",traced); ReflectionTestUtils.setField(service,"mutationRepository",new PdaPalletOperationRepository(traced)); ReflectionTestUtils.setField(service,"mapper",traced); ReflectionTestUtils.setField(service,"mutationRepository",new PdaPalletOperationRepository(traced));
ReflectionTestUtils.setField(service,"recoveryRepository",new PalletOperationRecoveryRepository(sessions.getMapper(PalletOperationRecoveryMapper.class)));
ReflectionTestUtils.setField(service,"physicalPalletSerial",observedSerial); ReflectionTestUtils.setField(service,"physical",physical); ReflectionTestUtils.setField(service,"physicalPalletSerial",observedSerial); ReflectionTestUtils.setField(service,"physical",physical);
ReflectionTestUtils.setField(service,"relabelApplications",applications); ReflectionTestUtils.setField(service,"relabelItems",sessions.getMapper(RelabelItemMapper.class)); ReflectionTestUtils.setField(service,"relabelApplications",applications); ReflectionTestUtils.setField(service,"relabelItems",sessions.getMapper(RelabelItemMapper.class));
ReflectionTestUtils.setField(service,"returnCartons",sessions.getMapper(ReturnInventoryCartonMapper.class)); ReflectionTestUtils.setField(service,"returnCartons",sessions.getMapper(ReturnInventoryCartonMapper.class));
@@ -480,8 +495,12 @@ class PdaPalletDispatchFreezePersistenceTest {
/** 真实库存余额保留其原始订单,跨客户位置各有独立行。 */ /** 真实库存余额保留其原始订单,跨客户位置各有独立行。 */
void position(long id,String customer,long balance,long pallet,long order,long held) { void position(long id,String customer,long balance,long pallet,long order,long held) {
db.update("INSERT INTO ll_order(id,customer_id) VALUES(?,?)",order,customer); // 入仓快照和尾程订单是不同主键,经同客户的来源身份解析,禁止借相同ID掩盖映射错误。
db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,overseas_warehouse_id,destination_warehouse_code) VALUES(?,?,?,'88','SCK8')",balance,customer,order); String source = "TEST-SOURCE-"+order;
long inbound = order+100000;
db.update("INSERT INTO ll_order(id,customer_id,source_inbound_id) VALUES(?,?,?)",order,customer,source);
db.update("INSERT INTO ll_inbound_order(id,customer_id,source_inbound_id) VALUES(?,?,?)",inbound,customer,source);
db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,overseas_warehouse_id,destination_warehouse_code) VALUES(?,?,?,'88','SCK8')",balance,customer,inbound);
String code = pallet==40?"TARGET":pallet==30?"SOURCE":"OTHER"; String code = pallet==40?"TARGET":pallet==30?"SOURCE":"OTHER";
db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,overseas_warehouse_id,pallet_id,pallet_no,batch_no,destination_warehouse_code,available_packages,hold_packages,allocated_packages,picked_packages,receiving_packages,version) VALUES(?,?,?,'88',?,?,'TEST','SCK8',?,?,?,?,?,0)",id,customer,balance,pallet,code,held==0?1:0,held,0,0,0); db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,overseas_warehouse_id,pallet_id,pallet_no,batch_no,destination_warehouse_code,available_packages,hold_packages,allocated_packages,picked_packages,receiving_packages,version) VALUES(?,?,?,'88',?,?,'TEST','SCK8',?,?,?,?,?,0)",id,customer,balance,pallet,code,held==0?1:0,held,0,0,0);
} }
@@ -62,6 +62,7 @@ class PdaPalletOperationServiceTest {
} }
@Mock private PdaPalletOperationMapper mapper; @Mock private PdaPalletOperationMapper mapper;
@Mock private PdaPalletOperationRepository mutationRepository; @Mock private PdaPalletOperationRepository mutationRepository;
@Mock private com.baiyue.lastleg.palletops.repository.PalletOperationRecoveryRepository recoveryRepository;
@Mock private PhysicalPalletSerialPort physicalPalletSerial; @Mock private PhysicalPalletSerialPort physicalPalletSerial;
@Mock private com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper inventoryCommands; @Mock private com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper inventoryCommands;
@Mock private InventoryPositionMapper positions; @Mock private InventoryPositionMapper positions;
@@ -82,6 +83,8 @@ class PdaPalletOperationServiceTest {
ReflectionTestUtils.setField(service, "cartonMeasurements", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.RelabelCartonMeasurementService.class)); ReflectionTestUtils.setField(service, "cartonMeasurements", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.RelabelCartonMeasurementService.class));
ReflectionTestUtils.setField(service, "mapper", mapper); ReflectionTestUtils.setField(service, "mapper", mapper);
ReflectionTestUtils.setField(service, "mutationRepository", mutationRepository); ReflectionTestUtils.setField(service, "mutationRepository", mutationRepository);
ReflectionTestUtils.setField(service, "recoveryRepository", recoveryRepository);
lenient().when(recoveryRepository.journal(anyLong(),anyString(),any())).thenReturn(true);
ReflectionTestUtils.setField(service, "physicalPalletSerial", physicalPalletSerial); ReflectionTestUtils.setField(service, "physicalPalletSerial", physicalPalletSerial);
// 既有单元测试只模拟本申请;真实全客户组成、SQL锁及竞争由持久化专项证明。 // 既有单元测试只模拟本申请;真实全客户组成、SQL锁及竞争由持久化专项证明。
lenient().when(mutationRepository.composition(anyString(), anyMap(), any())).thenAnswer(call -> { lenient().when(mutationRepository.composition(anyString(), anyMap(), any())).thenAnswer(call -> {
@@ -577,6 +580,8 @@ class PdaPalletOperationServiceTest {
} }
@Test void unboundReturnRelabelSourceDoesNotUnboxNullPalletIdentity() { @Test void unboundReturnRelabelSourceDoesNotUnboxNullPalletIdentity() {
ReflectionTestUtils.setField(service, "relabelWorkTasks",
mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class));
RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100"); RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100");
app.setRelabelNo("RL-1"); app.setOverseasWarehouseId("88"); app.setStatus("READY_FOR_OUTBOUND"); app.setRelabelNo("RL-1"); app.setOverseasWarehouseId("88"); app.setStatus("READY_FOR_OUTBOUND");
when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app); when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app);
@@ -76,6 +76,9 @@ class PdaRelabelServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new PdaRelabelService(); service = new PdaRelabelService();
var quantityRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.repository.RelabelQuantityRepository.class);
lenient().when(quantityRepository.sourceSnapshot(any())).thenReturn(java.util.Map.of("itemBindings", List.of()));
ReflectionTestUtils.setField(service, "quantityRepository", quantityRepository);
ReflectionTestUtils.setField(service, "skuConversions", skuConversions); ReflectionTestUtils.setField(service, "skuConversions", skuConversions);
ReflectionTestUtils.setField(service, "palletMeasurements", palletMeasurements); ReflectionTestUtils.setField(service, "palletMeasurements", palletMeasurements);
ReflectionTestUtils.setField(service, "applications", applications); ReflectionTestUtils.setField(service, "applications", applications);
@@ -12,6 +12,55 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
class RelabelQuantityServiceTest { class RelabelQuantityServiceTest {
/** 构造不可变历史供多轮和多来源回归使用。 */
private com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory history(String action, String json) {
var event = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory();
event.setAction(action); event.setDetailJson(json); return event;
}
@Test void exactBindingsKeepOriginalAndReplaceTargetsAcrossRebindAndRework() {
var a = item("CARTON_AND_SKU", "A", 1); a.setId(1L);
var b = item("CARTON_AND_SKU", "B", 1); b.setId(2L);
var first = history("PALLET_BIND", "{\"itemBindings\":[{\"itemId\":\"1\",\"sourceKnown\":true,\"sourcePalletCode\":\"P1\",\"targetPalletCode\":\"T1\"},{\"itemId\":\"2\",\"sourceKnown\":true,\"sourcePalletCode\":\"P2\",\"targetPalletCode\":\"T1\"}]}");
var second = history("PALLET_BIND", "{\"itemBindings\":[{\"itemId\":\"1\",\"sourceKnown\":true,\"sourcePalletCode\":\"T1\",\"targetPalletCode\":\"T2\"}]}");
var result = RelabelPalletDimensions.measure(List.of(a,b), List.of(first, second), new ObjectMapper());
assertEquals(java.util.Set.of("P1", "P2"), result.sources());
assertEquals(java.util.Set.of("T1", "T2"), result.targets());
result = RelabelPalletDimensions.measure(List.of(a,b), List.of(first, history("REWORK_ARCHIVE", "{}"), second), new ObjectMapper());
assertEquals(java.util.Set.of("P1", "P2"), result.sources()); assertNull(result.targets());
}
@Test void ambiguousLegacySourcesAndCurrentPositionsCannotInventOriginalPallets() {
var a = item("SKU_LABEL", "A", 1); a.setId(1L); a.setSourceInventoryPositionId(999L);
var result = RelabelPalletDimensions.measure(List.of(a), List.of(history("PALLET_BIND", "{\"cartonMarks\":[\"A\"],\"sourcePallets\":[\"P1\",\"P2\"],\"targetPallets\":[\"T1\"]}")), new ObjectMapper());
assertNull(result.sources()); assertEquals(java.util.Set.of("T1"), result.targets());
}
@Test void bulkSourceTypesUseExactIdsAndNeverTreatPalletOrParcelAsBoxes() {
var a = item("RETURN_BULK_SKU_LABEL", "M", 2); a.setId(1L);
var b = item("RETURN_BULK_SKU_LABEL", "M", 3); b.setId(2L);
var box = history("SOURCE_SNAPSHOT", "{\"itemBindings\":[{\"itemId\":\"1\",\"sourceUnitId\":\"9\",\"sourceUnitType\":\"BOX\",\"sourceKnown\":true,\"sourcePalletCode\":\"P\"},{\"itemId\":\"2\",\"sourceUnitId\":\"9\",\"sourceUnitType\":\"BOX\",\"sourceKnown\":true,\"sourcePalletCode\":\"P\"}]}");
var result = RelabelPalletDimensions.measure(List.of(a,b), List.of(box), new ObjectMapper());
assertEquals(1L, result.cartons()); assertEquals(java.util.Map.of("BOX",1L), result.sourceUnits());
for (var type : List.of("PALLET", "PARCEL")) {
box.setDetailJson(box.getDetailJson().replace("BOX", type));
result = RelabelPalletDimensions.measure(List.of(a,b), List.of(box), new ObjectMapper());
assertNull(result.cartons()); assertEquals(1L, result.sourceUnits().get(type));
box.setDetailJson(box.getDetailJson().replace(type, "BOX"));
}
}
@Test void allFilterPalletTotalsDeduplicateSameWarehouseAcrossOrders() {
var service = service(); var repo = mock(RelabelQuantityRepository.class);
ReflectionTestUtils.setField(service,"repository",repo);
var a = new RelabelApplication(); a.setRelabelNo("A"); a.setOverseasWarehouseId("W");
var b = new RelabelApplication(); b.setRelabelNo("B"); b.setOverseasWarehouseId("W");
var item = item("SKU_LABEL", "A", 1); item.setId(1L);
var event = history("PALLET_BIND", "{\"itemBindings\":[{\"itemId\":\"1\",\"sourceKnown\":true,\"sourcePalletCode\":\"P\",\"targetPalletCode\":\"T\"}]}");
var query = new RelabelRequests.Query(2,1,null,null,null,null,null,null,null);
when(repo.filtered(null,query)).thenReturn(List.of(a,b));
when(repo.items(a)).thenReturn(List.of(item)); when(repo.items(b)).thenReturn(List.of(item));
when(repo.history(a)).thenReturn(List.of(event)); when(repo.history(b)).thenReturn(List.of(event));
var result = service.totals(null,query);
assertEquals(1L,result.knownSourcePalletCount()); assertEquals(1L,result.knownTargetPalletCount());
assertEquals(0L,result.unknownSourcePalletOrders()); assertEquals(2,result.orderCount());
}
/** 以真实对象测试件数、箱数的不同业务口径。 */ /** 以真实对象测试件数、箱数的不同业务口径。 */
private RelabelQuantityService service() { private RelabelQuantityService service() {
var service = new RelabelQuantityService(); var service = new RelabelQuantityService();
@@ -54,7 +54,9 @@ class ReturnInventoryMovementSqlTest {
var boundSql = configuration.getMappedStatement(ReturnInventoryMovementMapper.class.getName() + "." + method) var boundSql = configuration.getMappedStatement(ReturnInventoryMovementMapper.class.getName() + "." + method)
.getBoundSql(params); .getBoundSql(params);
String sql = boundSql.getSql(); String sql = boundSql.getSql();
assertEquals(8, sql.split("UNION ALL", -1).length); // 原有八种库存流水加上正式废弃扣减分支,完整保留投影和客户过滤合同。
assertEquals(9, sql.split("UNION ALL", -1).length);
assertTrue(sql.contains("'DISCARD'"));
assertTrue(sql.contains("ll_return_carton_stock_receipt")); assertTrue(sql.contains("ll_return_carton_stock_receipt"));
assertTrue(!sql.contains("CONVERT(")); assertTrue(!sql.contains("CONVERT("));
assertTrue(sql.contains("m.customerId=?")); assertTrue(sql.contains("m.customerId=?"));
@@ -77,6 +77,7 @@ class LastlegOrderServiceTest {
@Mock ReturnOrderReferenceResolver returnOrderReferences; @Mock ReturnOrderReferenceResolver returnOrderReferences;
@Mock com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService returnReceipts; @Mock com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService returnReceipts;
LastlegOrderService service; LastlegOrderService service;
@Mock com.baiyue.lastleg.transfer.service.TransferQuantityService transferQuantities;
@Test @Test
void exposesWarehouseTransferNumberAsTailOrderNumber() { void exposesWarehouseTransferNumberAsTailOrderNumber() {
@@ -104,7 +105,7 @@ class LastlegOrderServiceTest {
LastlegOrderSummary summary = ReflectionTestUtils.invokeMethod( LastlegOrderSummary summary = ReflectionTestUtils.invokeMethod(
service, "summary", order, List.of(), null, "UNSCHEDULED", service, "summary", order, List.of(), null, "UNSCHEDULED",
new LastlegOrderMapper.OrderWeightRow(11L, new java.math.BigDecimal("30"), new java.math.BigDecimal("22")), List.of(), null); new LastlegOrderMapper.OrderWeightRow(11L, new java.math.BigDecimal("30"), new java.math.BigDecimal("22")), List.of(), null, null);
assertNotNull(summary); assertNotNull(summary);
assertEquals("BOOKING-1", summary.sourceContainerBookingId()); assertEquals("BOOKING-1", summary.sourceContainerBookingId());
@@ -117,6 +118,7 @@ class LastlegOrderServiceTest {
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo( com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
new org.apache.ibatis.builder.MapperBuilderAssistant(new org.apache.ibatis.session.Configuration(), ""), LastlegOrder.class); new org.apache.ibatis.builder.MapperBuilderAssistant(new org.apache.ibatis.session.Configuration(), ""), LastlegOrder.class);
service = new LastlegOrderService(); service = new LastlegOrderService();
ReflectionTestUtils.setField(service, "transferQuantities", transferQuantities);
ReflectionTestUtils.setField(service, "returnOrderReferences", returnOrderReferences); ReflectionTestUtils.setField(service, "returnOrderReferences", returnOrderReferences);
ReflectionTestUtils.setField(service, "orderMapper", orders); ReflectionTestUtils.setField(service, "orderMapper", orders);
ReflectionTestUtils.setField(service, "detailMapper", details); ReflectionTestUtils.setField(service, "detailMapper", details);
@@ -0,0 +1,63 @@
package com.baiyue.lastleg.transfer;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inventory.query.mapper.InventoryPieceMapper;
import com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class TransferQuantityMapperTest {
/** 使用真实MyBatis注册解析动态XML,避免仅javac放过未转义比较符。 */
@Test void registersBothDynamicQuantityMappers() {
var configuration = new MybatisConfiguration();
assertDoesNotThrow(() -> configuration.addMapper(TransferQuantityMapper.class));
assertDoesNotThrow(() -> configuration.addMapper(InventoryPieceMapper.class));
assertTrue(configuration.hasStatement(TransferQuantityMapper.class.getName() + ".rows"));
assertTrue(configuration.hasStatement(InventoryPieceMapper.class.getName() + ".rows"));
}
/** 真正执行SQL核对未知件数、出库后历史实收、同托重叠及客户隔离。 */
@Test void executesQuantityFactsWithoutLosingUnknownsOrShippedHistory() throws Exception {
var ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:transfer-quantity-" + System.nanoTime() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
sql.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR)");
sql.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR,lastleg_order_id BIGINT,package_count BIGINT)");
sql.execute("CREATE TABLE ll_warehouse_transfer_item(customer_id VARCHAR,transfer_order_id BIGINT,declared_quantity BIGINT)");
sql.execute("CREATE TABLE ll_warehouse_transfer_carton(customer_id VARCHAR,transfer_order_id BIGINT,transfer_pallet_id BIGINT,counted_at TIMESTAMP,status VARCHAR,counted_quantity BIGINT)");
sql.execute("CREATE TABLE ll_warehouse_transfer_pallet(id BIGINT,customer_id VARCHAR,warehouse_pallet_id BIGINT)");
sql.execute("INSERT INTO ll_order VALUES(1,'A'),(2,'A'),(3,'B')");
sql.execute("INSERT INTO ll_warehouse_transfer_order VALUES(11,'A',1,3),(12,'A',2,1),(13,'B',3,1)");
sql.execute("INSERT INTO ll_warehouse_transfer_item VALUES('A',11,10),('A',11,NULL),('A',12,4),('B',13,999)");
sql.execute("INSERT INTO ll_warehouse_transfer_pallet VALUES(21,'A',100),(22,'B',100)");
sql.execute("INSERT INTO ll_warehouse_transfer_carton VALUES('A',11,21,CURRENT_TIMESTAMP,'COUNTED',5),('A',11,21,CURRENT_TIMESTAMP,'SHIPPED',7),('A',11,NULL,NULL,'NOT_RECEIVED',NULL),('A',12,NULL,CURRENT_TIMESTAMP,'DISCARDED',NULL),('B',13,22,CURRENT_TIMESTAMP,'COUNTED',999)");
}
var configuration = new MybatisConfiguration();
configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
configuration.addMapper(TransferQuantityMapper.class);
try (var session = new MybatisSqlSessionFactoryBuilder().build(configuration).openSession()) {
var mapper = session.getMapper(TransferQuantityMapper.class);
var filter = new QueryWrapper<LastlegOrder>().eq("customer_id", "A").orderByAsc("id");
var rows = mapper.rows(filter);
assertEquals(2, rows.size());
var first = rows.get(0);
assertNull(first.declaredPieceCount());
assertEquals(12L, first.receivedPieceCount());
assertEquals(2L, first.receivedCartonCount());
assertEquals(1L, first.receivedPalletCount());
assertEquals(1L, first.remainingPalletCount());
assertEquals(1L, first.shippedPalletCount());
assertNull(rows.get(1).receivedPieceCount());
var pallets = mapper.pallets(filter);
assertEquals(2, pallets.size());
assertTrue(pallets.stream().allMatch(pallet -> pallet.customerId().equals("A")));
assertTrue(mapper.rows(new QueryWrapper<LastlegOrder>().eq("customer_id", "NONE")).isEmpty());
}
}
}
@@ -18,7 +18,7 @@ class MerchantWarehouseTransferControllerTest {
private final com.baiyue.lastleg.transfer.service.WarehouseTransferForecastService forecastService = mock(com.baiyue.lastleg.transfer.service.WarehouseTransferForecastService.class); private final com.baiyue.lastleg.transfer.service.WarehouseTransferForecastService forecastService = mock(com.baiyue.lastleg.transfer.service.WarehouseTransferForecastService.class);
private final MerchantWarehouseTransferController controller = controller(); private final MerchantWarehouseTransferController controller = controller();
private final WarehouseTransferRequests.Query query = new WarehouseTransferRequests.Query( private final WarehouseTransferRequests.Query query = new WarehouseTransferRequests.Query(
1, 20, null, null, null, null, null, null, null, null); 1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null, null);
@Test @Test
void merchantPageUsesOnlyAuthenticatedTenant() { void merchantPageUsesOnlyAuthenticatedTenant() {
@@ -26,6 +26,13 @@ class MerchantWarehouseTransferControllerTest {
verify(service).page("100", query); verify(service).page("100", query);
} }
/** 汇总只使用已认证客户,平台身份不可借客户入口读取数量。 */
@Test void quantityTotalsUsesOnlyAuthenticatedCustomer() {
controller.quantities(user(100L,"MERCHANT"),query);
verify(service).quantityTotals("100",query);
assertThrows(ResponseStatusException.class,() -> controller.quantities(user(0L,"PLATFORM"),query));
}
@Test @Test
void merchantDetailUsesOnlyAuthenticatedTenant() { void merchantDetailUsesOnlyAuthenticatedTenant() {
controller.detail(user(100L, "MERCHANT"), "TR001"); controller.detail(user(100L, "MERCHANT"), "TR001");
@@ -0,0 +1,57 @@
package com.baiyue.lastleg.transfer.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.transfer.dto.TransferQuantityView;
import com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper.PalletFact;
import com.baiyue.lastleg.transfer.repository.TransferQuantityRepository;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class TransferQuantityServiceTest {
/** 库存转运签收使用目的仓实收凭据,同时保留原仓已出库托盘。 */
@Test void destinationReceiptUsesExactOriginalPiecesAndPhysicalTargetPallets() {
var repository=mock(TransferQuantityRepository.class);
var service=new TransferQuantityService(); ReflectionTestUtils.setField(service,"repository",repository);
var filter=new QueryWrapper<LastlegOrder>().eq("id",3);
when(repository.rows(filter)).thenReturn(List.of(new TransferQuantityView(3L,14L,null,2,0,0,0,0,0,0)));
when(repository.movements(filter)).thenReturn(List.of(
new com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper.MovementFact(3L,"A",1,100L,"RECEIVED",1L,200L,10L),
new com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper.MovementFact(3L,"A",1,101L,"RECEIVED",2L,201L,4L)));
var result=service.totals(filter);
assertEquals(14L,result.receivedPieceCount()); assertEquals(2,result.receivedCartonCount());
assertEquals(2,result.receivedPalletCount()); assertEquals(2,result.shippedPalletCount());
assertEquals(2,result.shippedCartonCount());
}
/** 完整筛选总量保留未知,托盘按客户去重且剩余与出库集合允许重叠。 */
@Test void keepsUnknownsAndDeduplicatesOverlappingPalletsAcrossCustomers() {
var repository = mock(TransferQuantityRepository.class);
var service = new TransferQuantityService(); ReflectionTestUtils.setField(service,"repository",repository);
var filter = new QueryWrapper<LastlegOrder>().eq("order_type","WAREHOUSE_TRANSFER");
when(repository.rows(filter)).thenReturn(List.of(
new TransferQuantityView(1L,null,12L,3,2,1,1,1,1,1),
new TransferQuantityView(2L,4L,null,1,1,1,1,0,1,0)));
when(repository.pallets(filter)).thenReturn(List.of(new PalletFact("A",100L,"COUNTED"),
new PalletFact("A",100L,"SHIPPED"),new PalletFact("A",100L,"COUNTED"),new PalletFact("B",100L,"COUNTED")));
var totals = service.totals(filter);
assertEquals(2,totals.orderCount()); assertNull(totals.declaredPieceCount()); assertEquals(4,totals.knownDeclaredPieceCount());
assertEquals(1,totals.unknownDeclaredOrderCount()); assertNull(totals.receivedPieceCount());
assertEquals(12,totals.knownReceivedPieceCount()); assertEquals(1,totals.unknownReceivedOrderCount());
assertEquals(2,totals.receivedPalletCount()); assertEquals(2,totals.remainingPalletCount()); assertEquals(1,totals.shippedPalletCount());
verify(repository).rows(filter); verify(repository).pallets(filter);
}
/** 空筛选返回准确零值,非转运订单无需额外数量读取。 */
@Test void emptyFilterIsZeroAndNonTransferPageSkipsRepository() {
var repository = mock(TransferQuantityRepository.class);
var service = new TransferQuantityService(); ReflectionTestUtils.setField(service,"repository",repository);
var order = new LastlegOrder(); order.setOrderType("RETURN_RELABEL");
assertTrue(service.forOrders(List.of(order)).isEmpty()); verifyNoInteractions(repository);
var totals = service.totals(new QueryWrapper<LastlegOrder>().apply("1=0"));
assertEquals(0L,totals.declaredPieceCount()); assertEquals(0L,totals.receivedPieceCount()); assertEquals(0,totals.receivedPalletCount());
}
}
@@ -27,7 +27,6 @@ class WarehouseTransferCartonCountTest {
service = spy(new WarehouseTransferService()); service = spy(new WarehouseTransferService());
ReflectionTestUtils.setField(service, "transfers", orders); ReflectionTestUtils.setField(service, "transfers", orders);
var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class);
org.mockito.Mockito.lenient().when(pickupRepository.pdaSource(anyString(), anyString())).thenAnswer(call -> orders.lockForPda(call.getArgument(1), call.getArgument(0)));
ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper())); ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper()));
ReflectionTestUtils.setField(service, "transferCartons", cartons); ReflectionTestUtils.setField(service, "transferCartons", cartons);
ReflectionTestUtils.setField(service, "transferItems", items); ReflectionTestUtils.setField(service, "transferItems", items);
@@ -143,7 +143,7 @@ class WarehouseTransferServiceTest {
when(relabelApplications.selectList(any())).thenReturn(List.of(relabel)); when(relabelApplications.selectList(any())).thenReturn(List.of(relabel));
var result = service.page("TENANT-A", new WarehouseTransferRequests.Query( var result = service.page("TENANT-A", new WarehouseTransferRequests.Query(
1, 20, null, null, null, null, null, null, null, null)); 1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null, null));
assertEquals("RL-20260923-000001", result.records().get(0).relabelNo()); assertEquals("RL-20260923-000001", result.records().get(0).relabelNo());
assertEquals("COMPLETED", result.records().get(0).relabelStatus()); assertEquals("COMPLETED", result.records().get(0).relabelStatus());
@@ -162,7 +162,7 @@ class WarehouseTransferServiceTest {
when(transfers.selectPage(any(), any())).thenReturn(resultPage); when(transfers.selectPage(any(), any())).thenReturn(resultPage);
var result = service.page("TENANT-A", new WarehouseTransferRequests.Query( var result = service.page("TENANT-A", new WarehouseTransferRequests.Query(
1, 20, null, null, null, null, null, null, null, null)); 1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null, null));
assertEquals(1, result.records().size()); assertEquals(1, result.records().size());
assertNull(result.records().get(0).relabelStatus()); assertNull(result.records().get(0).relabelStatus());
@@ -233,7 +233,6 @@ class WarehouseTransferServiceTest {
}); });
ReflectionTestUtils.setField(service, "transfers", transfers); ReflectionTestUtils.setField(service, "transfers", transfers);
var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class);
org.mockito.Mockito.lenient().when(pickupRepository.pdaSource(anyString(), anyString())).thenAnswer(call -> transfers.lockForPda(call.getArgument(1), call.getArgument(0)));
ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper())); ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper()));
ReflectionTestUtils.setField(service, "transferItems", transferItems); ReflectionTestUtils.setField(service, "transferItems", transferItems);
ReflectionTestUtils.setField(service, "transferCartons", transferCartons); ReflectionTestUtils.setField(service, "transferCartons", transferCartons);
@@ -521,8 +520,6 @@ class WarehouseTransferServiceTest {
transfer.setInboundCode("IN-1"); transfer.setInboundCode("IN-1");
transfer.setNeedRelabel(true); transfer.setNeedRelabel(true);
transfer.setPackageCount(1); transfer.setPackageCount(1);
var selected = new WarehouseTransferCarton();
selected.setId(51L); selected.setCustomerCartonMark("CURRENT-BOX");
when(inboundSnapshots.sourceCartons("TENANT-A", "WH-1", "IN-1")) when(inboundSnapshots.sourceCartons("TENANT-A", "WH-1", "IN-1"))
.thenReturn(List.of(new InboundSnapshotMapper.SourceCarton("CURRENT-BOX", "SKU-1", null, null, null, null, null))); .thenReturn(List.of(new InboundSnapshotMapper.SourceCarton("CURRENT-BOX", "SKU-1", null, null, null, null, null)));
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);