fix(firstleg): settle and validate actual partial loading cargo

This commit is contained in:
2026-10-04 19:01:40 +08:00
parent 22133fe604
commit 7319fba35a
16 changed files with 936 additions and 49 deletions
@@ -8,6 +8,16 @@ import java.util.List;
@Mapper
public interface ContainerCustomsMapper {
/** Same settled loading-item scope as the V3 handover; retained skipped rows are not customs cargo. */
String ACTUAL_ITEM_SCOPE = """
EXISTS (SELECT 1 FROM fl_loading_pick_item actual_pick
JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id
WHERE actual_task.loading_plan_id=item.loading_plan_id
AND actual_pick.loading_plan_item_id=item.id
AND actual_pick.item_status IN ('PICKED','PARTIAL')
AND actual_pick.picked_package_count > 0
AND actual_pick.picked_package_count=item.package_count)
""";
String DOCUMENT_COLUMNS = """
d.id id,d.customs_container_id customsContainerId,d.tenant_id tenantId,d.file_id fileId,
d.file_name fileName,d.file_hash fileHash,d.content_type contentType,d.size_bytes sizeBytes,
@@ -36,6 +46,9 @@ public interface ContainerCustomsMapper {
JOIN fl_loading_plan_item item ON item.loading_plan_id=p.id AND item.deleted=0
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
WHERE p.id=#{planId}
AND (p.plan_status != 'COMPLETED' OR
""" + ACTUAL_ITEM_SCOPE + """
)
ORDER BY i.tenant_id,i.id
""")
List<InboundRow> planInbounds(@Param("planId") long planId);
@@ -46,6 +59,8 @@ public interface ContainerCustomsMapper {
JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED'
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
WHERE item.loading_plan_id=#{planId} AND item.deleted=0
AND
""" + ACTUAL_ITEM_SCOPE + """
AND (#{tenantId} IS NULL OR i.tenant_id=#{tenantId})
AND (#{taxMode} IS NULL OR i.tax_mode=#{taxMode})
""")
@@ -66,6 +81,8 @@ public interface ContainerCustomsMapper {
JOIN fl_loading_plan_item item ON item.loading_plan_id=p.id AND item.deleted=0
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
WHERE p.plan_status='COMPLETED' AND i.tenant_id=#{tenantId} AND i.order_id=#{orderId} AND i.tax_mode='TAX_REFUND'
AND
""" + ACTUAL_ITEM_SCOPE + """
ORDER BY COALESCE(p.confirmed_at,p.updated_at) DESC,p.id DESC LIMIT 1
""")
Long completedPlanForOrder(@Param("tenantId") long tenantId, @Param("orderId") long orderId);
@@ -325,6 +342,8 @@ public interface ContainerCustomsMapper {
SELECT DISTINCT i.id FROM fl_loading_plan_item item
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
WHERE item.loading_plan_id=#{planId} AND item.deleted=0 AND i.tax_mode='TAX_REFUND'
AND
""" + ACTUAL_ITEM_SCOPE + """
) target LEFT JOIN fl_customs_customer_document_inbound s ON s.inbound_id=target.id
LEFT JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.customs_container_id=#{containerId} AND d.status='ACTIVE'
WHERE d.id IS NULL
@@ -336,6 +355,8 @@ public interface ContainerCustomsMapper {
FROM fl_loading_plan_item item
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
WHERE item.loading_plan_id=#{planId} AND item.deleted=0 AND i.tax_mode='TAX_REFUND'
AND
""" + ACTUAL_ITEM_SCOPE + """
""")
int taxRefundCount(@Param("planId") long planId);
@@ -541,6 +562,8 @@ public interface ContainerCustomsMapper {
LEFT JOIN fl_outbound_dispatch d ON d.inbound_id=item.inbound_id
AND d.tenant_id=item.account_id AND d.container_booking_id=c.container_booking_id
WHERE item.loading_plan_id=c.loading_plan_id AND item.deleted=0
AND
""" + ACTUAL_ITEM_SCOPE + """
GROUP BY item.loading_plan_id HAVING COUNT(*)=SUM(CASE WHEN d.status='WAITING_FOR_LOADING' THEN 1 ELSE 0 END)
) ORDER BY c.completed_at,c.id LIMIT #{limit}
""")
@@ -557,6 +580,8 @@ public interface ContainerCustomsMapper {
JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION'
AND
""" + ACTUAL_ITEM_SCOPE + """
AND item.id>#{cursor} AND item.id<=#{maximumId}
ORDER BY item.id LIMIT #{limit}
""")
@@ -575,6 +600,8 @@ public interface ContainerCustomsMapper {
JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED'
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION'
AND
""" + ACTUAL_ITEM_SCOPE + """
""")
long maximumBuyItemId(@Param("planId") long planId);
@@ -583,6 +610,8 @@ public interface ContainerCustomsMapper {
JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED'
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION' AND item.id<=#{maximumId}
AND
""" + ACTUAL_ITEM_SCOPE + """
""")
long countBuyRows(@Param("planId") long planId,@Param("maximumId") long maximumId);
@@ -250,14 +250,29 @@ public interface InboundSplitSettlementMapper {
@Update("""
UPDATE fl_loading_plan_item plan_item
JOIN fl_loading_pick_item pick_item ON pick_item.loading_plan_item_id = plan_item.id
SET plan_item.pallet_count = CEIL(COALESCE(plan_item.pallet_count,0)
* pick_item.picked_package_count / NULLIF(plan_item.package_count,0)),
SET plan_item.pallet_count = CEIL(CAST(COALESCE(plan_item.pallet_count,0) AS DECIMAL(30,6))
* pick_item.picked_package_count / NULLIF(pick_item.planned_package_count,0)),
plan_item.product_quantity = CASE WHEN plan_item.units_per_carton IS NOT NULL
THEN plan_item.units_per_carton * pick_item.picked_package_count
ELSE ROUND(CAST(plan_item.product_quantity AS DECIMAL(30,6)) * pick_item.picked_package_count
/ NULLIF(pick_item.planned_package_count,0),0) END,
plan_item.gross_weight_kg = ROUND(plan_item.gross_weight_kg * pick_item.picked_package_count
/ NULLIF(pick_item.planned_package_count,0),3),
plan_item.net_weight_kg = ROUND(plan_item.net_weight_kg * pick_item.picked_package_count
/ NULLIF(pick_item.planned_package_count,0),3),
plan_item.dimensional_weight_kg = ROUND(plan_item.dimensional_weight_kg * pick_item.picked_package_count
/ NULLIF(pick_item.planned_package_count,0),3),
plan_item.volume_cbm = ROUND(plan_item.volume_cbm * pick_item.picked_package_count
/ NULLIF(pick_item.planned_package_count,0),4),
plan_item.package_count = pick_item.picked_package_count,
plan_item.updated_by = #{operatorId}, plan_item.updated_by_name = #{operatorName},
plan_item.updated_at = CURRENT_TIMESTAMP(3), plan_item.version = plan_item.version + 1
WHERE plan_item.id = #{planItemId} AND plan_item.deleted = 0
AND plan_item.unit_type = 'PACKAGE' AND plan_item.pallet_id IS NULL
AND pick_item.item_status = 'PARTIAL'
AND pick_item.picked_package_count > 0
AND pick_item.picked_package_count < pick_item.planned_package_count
AND plan_item.package_count = pick_item.planned_package_count
""")
int convergePartialPlanItem(@Param("planItemId") long planItemId, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@@ -13,7 +13,7 @@ import java.util.List;
/** 读取 PDA 结算后的最终实装入仓单和冻结货物快照。 */
@Mapper
public interface LoadingCompletedEventMapper {
@Select("""
String SNAPSHOT_ROWS = """
SELECT p.id AS loading_plan_id, p.plan_no, p.container_booking_id, p.version AS aggregate_version,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
i.customer_name,
@@ -29,7 +29,10 @@ public interface LoadingCompletedEventMapper {
i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity,
i.pallet_count, i.gross_weight_kg, i.net_weight_kg, i.dimensional_weight_kg,
i.volume_cbm, i.fba_tracking_no, i.shipment_no, i.shipping_mark
,i.customer_remark, i.internal_remark, i.warehouse_remark
,i.customer_remark, i.internal_remark, i.warehouse_remark,
i.units_per_carton, i.unit_type, i.pallet_id,
actual_pick.planned_package_count, actual_pick.picked_package_count,
actual_pick.item_status AS pick_status
FROM fl_loading_plan p
JOIN fl_container_booking b ON b.id = p.container_booking_id
JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0
@@ -37,17 +40,33 @@ public interface LoadingCompletedEventMapper {
ON actual_pick.loading_plan_item_id = i.id
AND actual_pick.item_status IN ('PICKED','PARTIAL')
AND actual_pick.picked_package_count > 0
AND actual_pick.picked_package_count = i.package_count
JOIN fl_loading_pick_task actual_task
ON actual_task.id = actual_pick.pick_task_id
AND actual_task.loading_plan_id = p.id
JOIN fl_inbound_order o ON o.id = i.inbound_id AND o.tenant_id = i.account_id AND o.deleted = 0
LEFT JOIN fl_inbound_order root_order ON root_order.id = COALESCE(o.root_inbound_id, o.id)
WHERE p.id = #{planId} AND i.package_count > 0
ORDER BY i.account_id, i.inbound_id, i.inbound_detail_id, i.id
""")
""";
String SNAPSHOT_ORDER = " ORDER BY i.account_id, i.inbound_id, i.inbound_detail_id, i.id";
@Select(SNAPSHOT_ROWS + " AND actual_pick.picked_package_count = i.package_count" + SNAPSHOT_ORDER)
List<Row> rows(@Param("planId") long planId);
/** 尚未结算的 PACKAGE/PARTIAL 也必须接受整柜预检;这里只读原始冻结行。 */
@Select(SNAPSHOT_ROWS + SNAPSHOT_ORDER)
List<Row> previewRows(@Param("planId") long planId);
@Select("""
SELECT COUNT(*) FROM fl_loading_plan_item i
JOIN fl_loading_pick_item actual_pick ON actual_pick.loading_plan_item_id=i.id
JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id
AND actual_task.loading_plan_id=i.loading_plan_id
WHERE i.loading_plan_id=#{planId} AND i.deleted=0
AND actual_pick.item_status IN ('PICKED','PARTIAL') AND actual_pick.picked_package_count>0
AND actual_pick.picked_package_count<>i.package_count
""")
int unsettledActualItems(@Param("planId") long planId);
/** 读取头程托盘及箱唛快照;普通订单向尾程交接打印编号,一键代发同时冻结托盘库存。 */
@Select("""
SELECT i.account_id, i.inbound_id AS source_inbound_id,
@@ -139,6 +158,8 @@ public interface LoadingCompletedEventMapper {
private String fbaTrackingNo; private String shipmentNo; private String shippingMark;
private String businessType; private String fulfillmentType; private String lxCode; private String destinationType;
private String customerRemark; private String internalRemark; private String warehouseRemark;
private Integer unitsPerCarton; private String unitType; private Long palletId;
private Integer plannedPackageCount; private Integer pickedPackageCount; private String pickStatus;
}
@Getter @Setter
@@ -18,7 +18,7 @@ public interface LoadingPalletLabelMapper {
i.id AS item_id,i.account_id,i.inbound_id,i.inbound_code,i.destination_warehouse_id,
i.destination_warehouse_code,i.destination_warehouse_name,
COALESCE(NULLIF(i.pallet_count,0),
CEIL(COALESCE(NULLIF(o.pallet_count,0),
CEIL(CAST(COALESCE(NULLIF(o.pallet_count,0),
NULLIF((SELECT COUNT(DISTINCT weight.id)
FROM fl_inbound_pallet_allocation allocation
JOIN fl_inbound_weight_result weight
@@ -31,7 +31,7 @@ public interface LoadingPalletLabelMapper {
AND allocation.allocation_status IN ('CONFIRMED','TRANSFERRED','RELEASED')),0),
(SELECT COUNT(*) FROM fl_inbound_weight_result weight
WHERE weight.tenant_id=i.account_id AND weight.inbound_id=i.inbound_id
AND weight.deleted=0 AND weight.recognition_status='CONFIRMED'),0)
AND weight.deleted=0 AND weight.recognition_status='CONFIRMED'),0) AS DECIMAL(30,6))
* i.package_count / NULLIF(o.total_package_count,0)),0) AS pallet_count,
COALESCE(o.fulfillment_type,'STANDARD') AS fulfillment_type,
o.destination_type,i.load_sequence
@@ -45,6 +45,44 @@ public interface LoadingPalletLabelMapper {
""")
List<SourceRow> lockSources(@Param("planId") long planId);
/** 预测结算后的来源范围和托数;不锁行、不生成或占用标签。 */
@Select("""
SELECT p.id AS plan_id,p.plan_no,p.plan_status,b.container_no,b.cabinet_no,b.warehouse_id,
i.id AS item_id,i.account_id,i.inbound_id,i.inbound_code,i.destination_warehouse_id,
i.destination_warehouse_code,i.destination_warehouse_name,
COALESCE(NULLIF(CASE WHEN pick.picked_package_count<i.package_count
THEN CEIL(CAST(COALESCE(i.pallet_count,0) AS DECIMAL(30,6))
* pick.picked_package_count/NULLIF(pick.planned_package_count,0))
ELSE i.pallet_count END,0),
CEIL(CAST(COALESCE(
NULLIF((SELECT COUNT(DISTINCT weight.id)
FROM fl_inbound_pallet_allocation allocation
JOIN fl_inbound_weight_result weight
ON weight.id=allocation.weight_result_id AND weight.tenant_id=allocation.tenant_id
AND weight.deleted=0 AND weight.recognition_status='CONFIRMED'
AND (weight.inbound_id=o.id OR weight.inbound_id=o.root_inbound_id)
WHERE allocation.tenant_id=i.account_id AND allocation.inbound_id=i.inbound_id
AND allocation.block_id>0 AND allocation.slot_no>0 AND allocation.reservation_id>0
AND allocation.allocated_package_count>0
AND allocation.allocation_status IN ('CONFIRMED','TRANSFERRED','RELEASED')),0),
(SELECT COUNT(*) FROM fl_inbound_weight_result weight
WHERE weight.tenant_id=i.account_id AND weight.inbound_id=i.inbound_id
AND weight.deleted=0 AND weight.recognition_status='CONFIRMED'),0) AS DECIMAL(30,6))
* pick.picked_package_count / NULLIF(o.total_package_count,0)),0) AS pallet_count,
COALESCE(o.fulfillment_type,'STANDARD') AS fulfillment_type,
o.destination_type,i.load_sequence
FROM fl_loading_plan p
JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
JOIN fl_loading_plan_item i ON i.loading_plan_id=p.id AND i.deleted=0
JOIN fl_loading_pick_item pick ON pick.loading_plan_item_id=i.id
AND pick.item_status IN ('PICKED','PARTIAL') AND pick.picked_package_count>0
JOIN fl_loading_pick_task task ON task.id=pick.pick_task_id AND task.loading_plan_id=p.id
LEFT JOIN fl_inbound_order o ON o.id=i.inbound_id AND o.tenant_id=i.account_id AND o.deleted=0
WHERE p.id=#{planId}
ORDER BY i.destination_warehouse_code,i.load_sequence,i.id
""")
List<SourceRow> previewSources(@Param("planId") long planId);
@Select("""
SELECT id AS label_id,loading_plan_id AS plan_id,loading_plan_item_id AS item_id,
account_id,inbound_id,inbound_code,container_no,destination_warehouse_code,
@@ -28,6 +28,7 @@ import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.LocalDateTime;
@@ -53,24 +54,33 @@ public class LoadingCompletedOutboxService {
@Resource private ObjectMapper objectMapper;
public void publish(long planId) {
preparedEnvelopes(planId).forEach(this::insert);
if (snapshotMapper.unsettledActualItems(planId) != 0) {
throw new BizException(ResultCode.CONFLICT, "实装明细尚未完成结算,不能发布尾程交接事件");
}
preparedEnvelopes(planId, false).forEach(this::insert);
}
/** 在任何入库结算前验证跨区交接快照,避免整托/箱唛冲突留下半完成状态。 */
public void requirePublishable(long planId) {
preparedEnvelopes(planId);
preparedEnvelopes(planId, true);
}
private List<EventEnvelope> preparedEnvelopes(long planId) {
List<Row> rows = snapshotMapper.rows(planId);
private List<EventEnvelope> preparedEnvelopes(long planId, boolean preview) {
List<Row> rows = preview ? snapshotMapper.previewRows(planId) : snapshotMapper.rows(planId);
if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件");
if (preview) rows.forEach(LoadingCompletedOutboxService::projectSettledRow);
validateContainerIdentity(rows);
validateCustomerSnapshots(rows);
palletLabelService.prepare(planId, rows.get(0).getWarehouseId());
List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels;
if (preview) {
standardLabels = palletLabelService.preview(planId, rows.get(0).getWarehouseId());
} else {
palletLabelService.prepare(planId, rows.get(0).getWarehouseId());
standardLabels = palletLabelMapper.labels(planId);
}
Map<Long, List<String>> cartonMarks = cartonLabelPools(rows);
List<PalletRow> palletRows = palletRows(planId);
Map<String, PalletManifest> palletManifests = palletManifests(palletRows);
List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels = palletLabelMapper.labels(planId);
return rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId,
java.util.TreeMap::new, java.util.stream.Collectors.toList()))
.entrySet().stream().map(entry -> envelope(entry.getKey(), entry.getValue(),
@@ -80,6 +90,37 @@ public class LoadingCompletedOutboxService {
.toList();
}
/** 与 convergePartialPlanItem 相同的冻结分配口径;已结算的行绝不再次缩放。 */
static void projectSettledRow(Row row) {
Integer actual = row.getPickedPackageCount();
Integer planned = row.getPlannedPackageCount();
Integer frozen = row.getPackageCount();
if (actual == null || actual <= 0 || planned == null || planned < actual) {
throw new BizException(ResultCode.CONFLICT, "装柜实拣数量与计划快照不一致");
}
if (actual.equals(frozen)) return;
if (!"PARTIAL".equals(row.getPickStatus()) || !"PACKAGE".equals(row.getUnitType())
|| row.getPalletId() != null || !planned.equals(frozen) || actual >= planned) {
throw new BizException(ResultCode.CONFLICT, "部分装柜尚未形成合法的冻结分配快照");
}
Integer units = row.getUnitsPerCarton();
row.setProductQuantity(units == null
? (row.getProductQuantity() == null ? null : scaled(BigDecimal.valueOf(row.getProductQuantity()), actual, planned, 0).intValueExact())
: Math.multiplyExact(units, actual));
row.setPalletCount(BigDecimal.valueOf(Objects.requireNonNullElse(row.getPalletCount(), 0))
.multiply(BigDecimal.valueOf(actual)).divide(BigDecimal.valueOf(planned), 0, RoundingMode.CEILING).intValueExact());
row.setGrossWeightKg(scaled(row.getGrossWeightKg(), actual, planned, 3));
row.setNetWeightKg(scaled(row.getNetWeightKg(), actual, planned, 3));
row.setDimensionalWeightKg(scaled(row.getDimensionalWeightKg(), actual, planned, 3));
row.setVolumeCbm(scaled(row.getVolumeCbm(), actual, planned, 4));
row.setPackageCount(actual);
}
private static BigDecimal scaled(BigDecimal value, int actual, int planned, int scale) {
return value == null ? null : value.multiply(BigDecimal.valueOf(actual))
.divide(BigDecimal.valueOf(planned), scale, RoundingMode.HALF_UP);
}
/** 已开展逐箱打托的入仓单使用现场事实;未开展的存量入仓单继续兼容原托盘分配快照。 */
private List<PalletRow> palletRows(long planId) {
int workPalletCount = snapshotMapper.workPalletCount(planId);
@@ -47,6 +47,28 @@ public class LoadingPalletLabelService {
@Transactional
public PalletLabelManifest prepare(long planId, long warehouseId) {
List<SourceRow> sources = mapper.lockSources(planId);
List<LabelRow> expected = expectedSources(sources, warehouseId);
List<LabelRow> existing = mapper.labels(planId);
if (existing.isEmpty()) {
expected.forEach(row -> {
row.setLabelId(IdWorker.getId());
if (mapper.insert(row) != 1) throw new BizException(ResultCode.CONFLICT, "托盘码生成失败,请刷新后重试");
});
existing = mapper.labels(planId);
}
requireSame(expected, existing);
return manifest(sources.get(0), existing);
}
/** 结算前验证预测标签,仅读取;不分配持久标识、锁行或新增标签。 */
public List<LabelRow> preview(long planId, long warehouseId) {
List<LabelRow> expected = expectedSources(mapper.previewSources(planId), warehouseId);
List<LabelRow> existing = mapper.labels(planId);
if (!existing.isEmpty()) requireSame(expected, existing);
return existing.isEmpty() ? expected : existing;
}
private List<LabelRow> expectedSources(List<SourceRow> sources, long warehouseId) {
if (sources.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在或没有有效明细");
SourceRow first = sources.get(0);
if (!Objects.equals(first.getWarehouseId(), warehouseId)) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不属于当前仓库");
@@ -64,14 +86,7 @@ public class LoadingPalletLabelService {
throw new BizException(ResultCode.CONFLICT, "散货明细缺少 FBA 仓信息,不能生成托盘码");
}
Map<Long, String> internalCodes = resolveInternalCodes(standard);
List<LabelRow> existing = mapper.labels(planId);
List<LabelRow> expected = expected(first, standard, containerNo, internalCodes);
if (existing.isEmpty()) {
expected.forEach(row -> { if (mapper.insert(row) != 1) throw new BizException(ResultCode.CONFLICT, "托盘码生成失败,请刷新后重试"); });
existing = mapper.labels(planId);
}
requireSame(expected, existing);
return manifest(first, existing);
return expected(first, standard, containerNo, internalCodes);
}
@Transactional
@@ -103,7 +118,7 @@ public class LoadingPalletLabelService {
for (int local = 0; local < source.getPalletCount(); local++) {
int index = indexes.merge(source.getDestinationWarehouseId(), 1, Integer::sum);
if (index > 999) throw new BizException(ResultCode.CONFLICT, "单柜同一 FBA 仓托盘数不能超过 999");
LabelRow row = new LabelRow(); row.setLabelId(IdWorker.getId()); row.setPlanId(plan.getPlanId());
LabelRow row = new LabelRow(); row.setPlanId(plan.getPlanId());
row.setItemId(source.getItemId()); row.setAccountId(source.getAccountId()); row.setInboundId(source.getInboundId());
row.setInboundCode(source.getInboundCode()); row.setContainerNo(containerNo);
row.setDestinationWarehouseCode(warehouseCode); row.setDestinationWarehouseName(source.getDestinationWarehouseName());
@@ -1094,11 +1094,17 @@ BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细修改备注时不得变更其他业务字段';
END IF;
ELSEIF NOT EXISTS (
SELECT 1 FROM fl_loading_pick_item
WHERE loading_plan_item_id = OLD.id
AND item_status = 'PARTIAL'
SELECT 1 FROM fl_loading_pick_item actual_pick
JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id
WHERE actual_pick.loading_plan_item_id = OLD.id
AND actual_task.loading_plan_id = OLD.loading_plan_id
AND actual_pick.item_status = 'PARTIAL'
AND OLD.unit_type = 'PACKAGE' AND OLD.pallet_id IS NULL
AND actual_pick.picked_package_count > 0
AND actual_pick.picked_package_count < actual_pick.planned_package_count
AND OLD.package_count = actual_pick.planned_package_count
AND NEW.deleted = 0
AND NEW.package_count = picked_package_count
AND NEW.package_count = actual_pick.picked_package_count
) OR NEW.deleted <> OLD.deleted
OR NOT (NEW.customer_name <=> OLD.customer_name)
OR NOT (NEW.inbound_id <=> OLD.inbound_id)
@@ -1114,13 +1120,13 @@ BEGIN
OR NOT (NEW.product_name_en <=> OLD.product_name_en)
OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id)
OR NOT (NEW.product_image_url <=> OLD.product_image_url)
OR NOT (NEW.product_quantity <=> OLD.product_quantity)
OR NOT (NEW.product_quantity <=> CASE WHEN OLD.units_per_carton IS NOT NULL THEN OLD.units_per_carton * NEW.package_count ELSE ROUND(CAST(OLD.product_quantity AS DECIMAL(30,6)) * NEW.package_count / NULLIF(OLD.package_count,0),0) END)
OR NOT (NEW.units_per_carton <=> OLD.units_per_carton)
OR NOT (NEW.pallet_count <=> OLD.pallet_count)
OR NOT (NEW.gross_weight_kg <=> OLD.gross_weight_kg)
OR NOT (NEW.net_weight_kg <=> OLD.net_weight_kg)
OR NOT (NEW.dimensional_weight_kg <=> OLD.dimensional_weight_kg)
OR NOT (NEW.volume_cbm <=> OLD.volume_cbm)
OR NOT (NEW.pallet_count <=> CEIL(CAST(COALESCE(OLD.pallet_count,0) AS DECIMAL(30,6)) * NEW.package_count / NULLIF(OLD.package_count,0)))
OR NOT (NEW.gross_weight_kg <=> ROUND(OLD.gross_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3))
OR NOT (NEW.net_weight_kg <=> ROUND(OLD.net_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3))
OR NOT (NEW.dimensional_weight_kg <=> ROUND(OLD.dimensional_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3))
OR NOT (NEW.volume_cbm <=> ROUND(OLD.volume_cbm * NEW.package_count / NULLIF(OLD.package_count,0),4))
OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no)
OR NOT (NEW.shipment_no <=> OLD.shipment_no)
OR NOT (NEW.shipping_mark <=> OLD.shipping_mark)
@@ -1132,7 +1138,7 @@ BEGIN
OR NOT (NEW.removed_by <=> OLD.removed_by)
OR NOT (NEW.removed_by_name <=> OLD.removed_by_name)
OR NOT (NEW.removed_at <=> OLD.removed_at) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数';
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细仅允许按冻结分配比例收敛实际装载';
END IF;
END IF;
IF OLD.frozen_at IS NOT NULL THEN
@@ -0,0 +1,135 @@
package com.baiyue.firstleg.customs.container;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/** Real mapper checks: customs must use physical loading scope, not retained skipped plan rows. */
class ContainerCustomsActualCargoTest {
private JdbcTemplate jdbc;
private ContainerCustomsMapper mapper;
@BeforeEach
void setup() throws Exception {
var ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:customs-loaded-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
jdbc = new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT,plan_no VARCHAR,container_booking_id BIGINT,plan_status VARCHAR,confirmed_at TIMESTAMP,updated_at TIMESTAMP)");
jdbc.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR,record_status VARCHAR)");
jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,loading_plan_id BIGINT,inbound_id BIGINT,account_id BIGINT,deleted INT,package_count INT,customer_name VARCHAR,order_no VARCHAR,inbound_code VARCHAR,sku_code VARCHAR,product_name_zh VARCHAR,product_name_en VARCHAR,product_quantity INT,gross_weight_kg DECIMAL,net_weight_kg DECIMAL,volume_cbm DECIMAL,shipping_mark VARCHAR,customer_remark VARCHAR)");
jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT,tenant_id BIGINT,order_id BIGINT,order_no VARCHAR,inbound_code VARCHAR,customer_name VARCHAR,tax_mode VARCHAR,deleted INT)");
jdbc.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT)");
jdbc.execute("CREATE TABLE fl_loading_pick_item(loading_plan_item_id BIGINT,pick_task_id BIGINT,item_status VARCHAR,picked_package_count INT)");
jdbc.execute("CREATE TABLE fl_customs_customer_document_inbound(document_id BIGINT,inbound_id BIGINT)");
jdbc.execute("CREATE TABLE fl_customs_customer_document(id BIGINT,customs_container_id BIGINT,status VARCHAR)");
jdbc.execute("CREATE TABLE fl_customs_container(id BIGINT,loading_plan_id BIGINT,container_booking_id BIGINT,status VARCHAR,completed_at TIMESTAMP)");
jdbc.execute("CREATE TABLE fl_outbound_dispatch(inbound_id BIGINT,tenant_id BIGINT,container_booking_id BIGINT,status VARCHAR)");
jdbc.update("INSERT INTO fl_loading_plan VALUES(1,'PLAN',10,'COMPLETED',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)");
jdbc.update("INSERT INTO fl_container_booking VALUES(10,'CAB','CONTAINER','ACTIVE')");
jdbc.update("INSERT INTO fl_loading_pick_task VALUES(100,1),(200,2)");
inbound(1, 11, "TAX_REFUND");
inbound(2, 22, "TAX_REFUND");
inbound(3, 11, "BUY_DECLARATION");
inbound(4, 22, "BUY_DECLARATION");
item(1, 1, 11, 2, "PICKED", 2, 100);
item(2, 1, 11, 1, "PARTIAL", 1, 100);
item(3, 1, 11, 1, "SKIPPED", 0, 100); // same inbound, skipped SKU
item(4, 2, 22, 1, "SKIPPED", 0, 100); // entirely skipped customer/inbound
item(5, 3, 11, 1, "PICKED", 1, 100);
item(6, 4, 22, 1, "SKIPPED", 0, 100);
item(7, 2, 22, 1, "PICKED", 1, 200); // different task/plan cannot prove loading
item(8, 2, 22, 2, "PARTIAL", 1, 100); // unreconciled allocation
item(9, 2, 22, 1, "PICKED", 1, 100);
jdbc.update("UPDATE fl_loading_plan_item SET deleted=1 WHERE id=9");
var configuration = new MybatisConfiguration();
configuration.setMapUnderscoreToCamelCase(true);
configuration.addMapper(ContainerCustomsMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(ds);
factory.setConfiguration(configuration);
mapper = new SqlSessionTemplate(factory.getObject()).getMapper(ContainerCustomsMapper.class);
}
@Test
void actualCountsAndInboundScopeUseSettledPicksWithCustomerAndTaxIsolation() {
assertThat(mapper.actualBoxCount(1, null, null)).isEqualTo(4);
assertThat(mapper.actualBoxCount(1, 11L, "TAX_REFUND")).isEqualTo(3);
assertThat(mapper.actualBoxCount(1, 22L, "TAX_REFUND")).isZero();
assertThat(mapper.planInbounds(1)).extracting(ContainerCustomsMapper.InboundRow::inboundId)
.containsExactly(1L, 3L);
assertThat(mapper.completedPlanForOrder(11, 1)).isEqualTo(1);
assertThat(mapper.completedPlanForOrder(22, 2)).isNull();
}
@Test
void skippedRefundTicketDoesNotBlockCoverageOrCreateAFalseRefundRequirement() {
assertThat(mapper.taxRefundCount(1)).isEqualTo(1);
assertThat(mapper.missingRefundCount(1, 90)).isEqualTo(1);
jdbc.update("INSERT INTO fl_customs_customer_document VALUES(80,90,'ACTIVE')");
jdbc.update("INSERT INTO fl_customs_customer_document_inbound VALUES(80,1)");
assertThat(mapper.missingRefundCount(1, 90)).isZero();
}
@Test
void entirelySkippedRefundCargoLeavesARealBuyDeclarationOnlyContainer() {
jdbc.update("UPDATE fl_loading_pick_item SET item_status='SKIPPED',picked_package_count=0 WHERE loading_plan_item_id IN (1,2)");
assertThat(mapper.taxRefundCount(1)).isZero();
assertThat(mapper.missingRefundCount(1, 90)).isZero();
assertThat(mapper.actualBoxCount(1, null, null)).isEqualTo(1);
assertThat(mapper.planInbounds(1)).singleElement().satisfies(row ->
assertThat(row.taxMode()).isEqualTo("BUY_DECLARATION"));
assertThat(mapper.completedPlanForOrder(11, 1)).isNull();
}
@Test
void buyExportCursorCountAndRowsExcludeSkippedCargoConsistently() {
assertThat(mapper.maximumBuyItemId(1)).isEqualTo(5);
assertThat(mapper.countBuyRows(1, 100)).isEqualTo(1);
assertThat(mapper.buyRows(1, 0, 100, 20)).singleElement().satisfies(row -> {
assertThat(row.itemId()).isEqualTo(5);
assertThat(row.packageCount()).isEqualTo(1);
});
assertThat(mapper.buyRows(1, 5, 100, 20)).isEmpty();
}
@Test
void pendingDispatchIgnoresSkippedRowsButStillFindsUndispatchedActualCargo() throws Exception {
jdbc.update("INSERT INTO fl_customs_container VALUES(90,1,10,'COMPLETED',CURRENT_TIMESTAMP)");
// H2 cannot bind the outer alias inside this LEFT JOIN ON. Bind only that
// correlation to this fixture's container; execute the remaining production SQL unchanged.
String sql = String.join("\n", ContainerCustomsMapper.class.getMethod("pendingAutoDispatch", int.class)
.getAnnotation(org.apache.ibatis.annotations.Select.class).value())
.replace("d.container_booking_id=c.container_booking_id", "d.container_booking_id=10")
.replace("#{limit}", "20");
assertThat(jdbc.queryForList(sql, Long.class)).containsExactly(10L);
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(1,11,10,'WAITING_FOR_LOADING'),(3,11,10,'WAITING_FOR_LOADING')");
assertThat(jdbc.queryForList(sql, Long.class)).isEmpty();
}
@Test
void preCompletionInboundPreparationKeepsItsPlannedScopeAndNoActualBoxCount() {
jdbc.update("UPDATE fl_loading_plan SET plan_status='LOADING' WHERE id=1");
assertThat(mapper.planInbounds(1)).hasSize(4);
assertThat(mapper.actualBoxCount(1, null, null)).isZero();
assertThat(mapper.completedPlanForOrder(11, 1)).isNull();
}
private void inbound(long id, long tenant, String taxMode) {
jdbc.update("INSERT INTO fl_inbound_order VALUES(?,?,?,'ORDER','INBOUND','TEST',?,0)", id, tenant, id, taxMode);
}
private void item(long id, long inbound, long tenant, int packages, String status, int picked, long task) {
jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,inbound_id,account_id,deleted,package_count,product_quantity) VALUES(?,1,?,?,0,?,?)",
id, inbound, tenant, packages, packages * 10);
jdbc.update("INSERT INTO fl_loading_pick_item VALUES(?,?,?,?)", id, task, status, picked);
}
}
@@ -0,0 +1,103 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper;
import org.apache.ibatis.annotations.Update;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.math.BigDecimal;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/** Persist real SET expressions; adapt only MySQL UPDATE JOIN to H2 MERGE syntax. */
class PartialLoadingMetricsPersistenceTest {
private JdbcTemplate jdbc;
private String update;
@BeforeEach
void setup() throws Exception {
var ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:partial-metrics-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
jdbc = new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,package_count INT,product_quantity INT,units_per_carton INT,pallet_count INT,gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3),dimensional_weight_kg DECIMAL(18,3),volume_cbm DECIMAL(18,4),unit_type VARCHAR,pallet_id BIGINT,deleted INT,updated_by BIGINT,updated_by_name VARCHAR,updated_at TIMESTAMP,version INT)");
jdbc.execute("CREATE TABLE fl_loading_pick_item(loading_plan_item_id BIGINT,planned_package_count INT,picked_package_count INT,item_status VARCHAR)");
String mysql = String.join("\n", InboundSplitSettlementMapper.class
.getMethod("convergePartialPlanItem", long.class, long.class, String.class)
.getAnnotation(Update.class).value());
String assignments = mysql.substring(mysql.indexOf("SET ") + 4, mysql.indexOf("WHERE "));
String conditions = mysql.substring(mysql.indexOf("WHERE ") + 6);
update = ("MERGE INTO fl_loading_plan_item plan_item USING fl_loading_pick_item pick_item "
+ "ON pick_item.loading_plan_item_id=plan_item.id WHEN MATCHED AND (" + conditions
+ ") THEN UPDATE SET " + assignments)
.replace("#{operatorId}", "9").replace("#{operatorName}", "'TEST'").replace("#{planItemId}", "?");
}
@Test
void oneActualCartonUsesItsFrozenUnitsAndHalfTheTwoCartonMetrics() {
seed(1, 2, 1, 12, 24, 2, "25", "22", "18.5", "0.1234");
assertThat(jdbc.update(update, 1)).isEqualTo(1);
assertMetrics(1, 1, 12, 1, "12.500", "11.000", "9.250", "0.0617");
}
@Test
void nonHalfRatioUsesDeclaredUnitsAndDatabasePrecisionWithoutTruncation() {
seed(1, 3, 2, 7, 21, 2, "10.001", "8.002", "6.004", "0.1001");
assertThat(jdbc.update(update, 1)).isEqualTo(1);
assertMetrics(1, 2, 14, 2, "6.667", "5.335", "4.003", "0.0667");
}
@Test
void missingUnitsUsesFrozenQuantityRatioAndPreservesUnknownMeasures() {
seed(1, 3, 2, null, 10, null, null, null, null, null);
assertThat(jdbc.update(update, 1)).isEqualTo(1);
assertMetrics(1, 2, 7, 0, null, null, null, null);
seed(2, 3, 2, null, null, null, null, null, null, null);
assertThat(jdbc.update(update, 2)).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT product_quantity FROM fl_loading_plan_item WHERE id=2", Integer.class)).isNull();
}
@Test
void alreadyConvergedRowDoesNotScaleAgainOrAdvanceVersion() {
seed(1, 2, 1, 12, 24, 2, "25", "22", "18.5", "0.1234");
assertThat(jdbc.update(update, 1)).isEqualTo(1);
var after = jdbc.queryForMap("SELECT * FROM fl_loading_plan_item WHERE id=1");
assertThat(jdbc.update(update, 1)).isZero();
assertThat(jdbc.queryForMap("SELECT * FROM fl_loading_plan_item WHERE id=1")).isEqualTo(after);
}
@Test
void pendingSkippedFullAndPalletUnitsCannotEnterPartialConvergence() {
for (int id = 1; id <= 4; id++) seed(id, 2, 1, 12, 24, 2, "25", "22", "18.5", "0.1234");
jdbc.update("UPDATE fl_loading_pick_item SET item_status='PENDING' WHERE loading_plan_item_id=1");
jdbc.update("UPDATE fl_loading_pick_item SET item_status='SKIPPED',picked_package_count=0 WHERE loading_plan_item_id=2");
jdbc.update("UPDATE fl_loading_pick_item SET item_status='PICKED',picked_package_count=2 WHERE loading_plan_item_id=3");
jdbc.update("UPDATE fl_loading_plan_item SET unit_type='SEALED_PALLET',pallet_id=99 WHERE id=4");
var before = jdbc.queryForList("SELECT * FROM fl_loading_plan_item ORDER BY id");
for (int id = 1; id <= 4; id++) assertThat(jdbc.update(update, id)).isZero();
assertThat(jdbc.queryForList("SELECT * FROM fl_loading_plan_item ORDER BY id")).isEqualTo(before);
}
private void seed(long id, int planned, int picked, Integer units, Integer quantity, Integer pallets,
String gross, String net, String dimensional, String volume) {
jdbc.update("INSERT INTO fl_loading_plan_item VALUES(?,?,?,?,?,?,?,?,?,'PACKAGE',NULL,0,NULL,NULL,NULL,0)",
id, planned, quantity, units, pallets, decimal(gross), decimal(net), decimal(dimensional), decimal(volume));
jdbc.update("INSERT INTO fl_loading_pick_item VALUES(?,?,?,'PARTIAL')", id, planned, picked);
}
private void assertMetrics(long id, int boxes, Integer quantity, Integer pallets,
String gross, String net, String dimensional, String volume) {
var row = jdbc.queryForMap("SELECT * FROM fl_loading_plan_item WHERE id=?", id);
assertThat(row.get("PACKAGE_COUNT")).isEqualTo(boxes);
assertThat(row.get("PRODUCT_QUANTITY")).isEqualTo(quantity);
assertThat(row.get("PALLET_COUNT")).isEqualTo(pallets);
assertThat((BigDecimal) row.get("GROSS_WEIGHT_KG")).isEqualTo(decimal(gross));
assertThat((BigDecimal) row.get("NET_WEIGHT_KG")).isEqualTo(decimal(net));
assertThat((BigDecimal) row.get("DIMENSIONAL_WEIGHT_KG")).isEqualTo(decimal(dimensional));
assertThat((BigDecimal) row.get("VOLUME_CBM")).isEqualTo(decimal(volume));
}
private BigDecimal decimal(String value) { return value == null ? null : new BigDecimal(value); }
}
@@ -16,6 +16,38 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class PdaSplitSettlementSqlContractTest {
@Test
void partialMetricsGuardStaysIdenticalInModuleBaselineAndControlledUpdate() throws Exception {
String module = new ClassPathResource("db/phase3-smart-loading-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"));
String update = Files.readString(Path.of("..", "deploy", "sql", "migrations", "cn",
"baiyue_firstleg", "V7__settle_partial_loaded_cargo.sql"));
String trigger = loadingItemGuard(module);
assertThat(loadingItemGuard(unified)).isEqualTo(trigger);
assertThat(loadingItemGuard(update)).isEqualTo(trigger);
assertThat(trigger)
.contains("actual_task.loading_plan_id = OLD.loading_plan_id")
.contains("OLD.unit_type = 'PACKAGE' AND OLD.pallet_id IS NULL")
.contains("OLD.package_count = actual_pick.planned_package_count")
.contains("actual_pick.picked_package_count < actual_pick.planned_package_count")
.contains("NEW.package_count = actual_pick.picked_package_count")
.contains("NEW.product_quantity <=> CASE WHEN OLD.units_per_carton IS NOT NULL")
.contains("NEW.gross_weight_kg <=> ROUND(OLD.gross_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3)")
.contains("NEW.volume_cbm <=> ROUND(OLD.volume_cbm * NEW.package_count / NULLIF(OLD.package_count,0),4)")
.contains("IF OLD.frozen_at IS NOT NULL THEN")
.contains("装柜完成后的明细永久冻结")
.contains("NEW.inbound_detail_id <=> OLD.inbound_detail_id");
}
private static String loadingItemGuard(String sql) {
int start = sql.indexOf("CREATE TRIGGER trg_loading_item_before_update");
assertThat(start).isGreaterThanOrEqualTo(0);
int end = sql.indexOf("END$$", start);
assertThat(end).isGreaterThan(start);
return sql.substring(start, end + 5).replaceAll("\\s+", " ").trim();
}
@Test
void addsPreloadConfirmationAuditColumnsForIncrementalPdaAllocation() throws Exception {
String moduleSql = new ClassPathResource("db/phase3-split-preload-schema.sql")
@@ -133,7 +165,7 @@ class PdaSplitSettlementSqlContractTest {
.getAnnotation(Update.class).value()[0];
assertThat(converge)
.contains("plan_item.pallet_count = CEIL")
.contains("pick_item.picked_package_count / NULLIF(plan_item.package_count,0)")
.contains("pick_item.picked_package_count / NULLIF(pick_item.planned_package_count,0)")
.contains("plan_item.package_count = pick_item.picked_package_count");
}
@@ -0,0 +1,249 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper;
import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.*;
/** Real annotated Mapper query against persisted, not-yet-settled PDA facts. */
class LoadingCompletedPreflightDatabaseTest {
private JdbcTemplate jdbc;
private SqlSession session;
private LoadingCompletedEventMapper snapshots;
private LoadingCompletedOutboxService service;
private final LoadingPalletLabelService labels = mock(LoadingPalletLabelService.class);
private final InboundOutboxMapper outbox = mock(InboundOutboxMapper.class);
@BeforeEach void setup() {
var ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:loading-preflight-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
jdbc = new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT,plan_no VARCHAR,container_booking_id BIGINT,version INT)");
jdbc.execute("CREATE TABLE fl_container_booking(id BIGINT,container_no VARCHAR,cabinet_no VARCHAR,warehouse_id BIGINT,warehouse_name VARCHAR,estimated_port_arrival_at TIMESTAMP,actual_port_arrival_at TIMESTAMP,estimated_warehouse_arrival_at TIMESTAMP)");
jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT,tenant_id BIGINT,root_inbound_id BIGINT,order_id BIGINT,order_no VARCHAR,inbound_code VARCHAR,business_type VARCHAR,fulfillment_type VARCHAR,lx_code VARCHAR,destination_type VARCHAR,destination_warehouse_id BIGINT,destination_code VARCHAR,destination_name VARCHAR,deleted INT)");
jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,loading_plan_id BIGINT,account_id BIGINT,inbound_id BIGINT,inbound_detail_id BIGINT,customer_name VARCHAR,sku_code VARCHAR,product_name_zh VARCHAR,product_name_en VARCHAR,package_count INT,product_quantity INT,units_per_carton INT,pallet_count INT,gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3),dimensional_weight_kg DECIMAL(18,3),volume_cbm DECIMAL(18,4),fba_tracking_no VARCHAR,shipment_no VARCHAR,shipping_mark VARCHAR,customer_remark VARCHAR,internal_remark VARCHAR,warehouse_remark VARCHAR,deleted INT,unit_type VARCHAR,pallet_id BIGINT)");
jdbc.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT)");
jdbc.execute("CREATE TABLE fl_loading_pick_item(id BIGINT,loading_plan_item_id BIGINT,pick_task_id BIGINT,item_status VARCHAR,picked_package_count INT,planned_package_count INT)");
jdbc.execute("ALTER TABLE fl_loading_plan ADD plan_status VARCHAR DEFAULT 'LOADING'");
jdbc.execute("ALTER TABLE fl_container_booking ADD record_status VARCHAR DEFAULT 'ACTIVE'");
jdbc.execute("ALTER TABLE fl_inbound_order ADD total_package_count INT");
jdbc.execute("ALTER TABLE fl_inbound_order ADD pallet_count INT");
for (String definition : List.of("inbound_code VARCHAR","destination_warehouse_id BIGINT","destination_warehouse_code VARCHAR",
"destination_warehouse_name VARCHAR","load_sequence INT","updated_by BIGINT","updated_by_name VARCHAR","updated_at TIMESTAMP","version INT DEFAULT 0")) {
jdbc.execute("ALTER TABLE fl_loading_plan_item ADD " + definition);
}
jdbc.update("INSERT INTO fl_loading_plan(id,plan_no,container_booking_id,version) VALUES(90,'LP90',91,2)");
jdbc.update("INSERT INTO fl_container_booking(id,container_no,cabinet_no,warehouse_id,warehouse_name) VALUES(91,'MSCU6639870','D0091',1,'TEST')");
jdbc.update("INSERT INTO fl_loading_pick_task VALUES(92,90)");
Configuration config = new Configuration(new Environment("test", new JdbcTransactionFactory(), ds));
config.setMapUnderscoreToCamelCase(true);
config.addMapper(LoadingCompletedEventMapper.class);
config.addMapper(LoadingPalletLabelMapper.class);
session = new SqlSessionFactoryBuilder().build(config).openSession(true);
snapshots = session.getMapper(LoadingCompletedEventMapper.class);
service = new LoadingCompletedOutboxService();
ReflectionTestUtils.setField(service,"snapshotMapper",snapshots);
ReflectionTestUtils.setField(service,"cartonManifestMapper",mock(LoadingCartonManifestMapper.class));
ReflectionTestUtils.setField(service,"palletLabelMapper",mock(LoadingPalletLabelMapper.class));
ReflectionTestUtils.setField(service,"palletLabelService",labels);
ReflectionTestUtils.setField(service,"outboxMapper",outbox);
ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper().findAndRegisterModules());
var evidence = mock(CabinetLoadingEvidenceMapper.class);
when(evidence.byContainer(anyLong())).thenReturn(List.of(photo("PHOTO_STEP_1"),photo("PHOTO_STEP_2"),photo("PHOTO_STEP_3"),photo("PHOTO_STEP_4"),photo("SEAL")));
ReflectionTestUtils.setField(service,"cabinetEvidenceMapper",evidence);
// Unrelated sealed-pallet data is empty; the actual plan and pick rows remain real SQL.
jdbc.execute("CREATE TABLE fl_dropship_pallet(id BIGINT,pallet_status VARCHAR,pallet_no VARCHAR)");
jdbc.execute("CREATE TABLE fl_dropship_pallet_carton(id BIGINT,pallet_id BIGINT,tenant_id BIGINT,inbound_id BIGINT,inbound_detail_id BIGINT,sku_code VARCHAR,box_code VARCHAR,actual_sku_quantity INT)");
jdbc.execute("CREATE TABLE fl_dropship_pallet_carton_sku(carton_id BIGINT,inbound_detail_id BIGINT,sku_code VARCHAR,actual_quantity INT)");
jdbc.execute("CREATE TABLE fl_inbound_pallet_allocation(tenant_id BIGINT,inbound_id BIGINT,pallet_id BIGINT,reservation_id BIGINT,pallet_code VARCHAR,allocation_no INT,picked_package_count INT,allocation_status VARCHAR)");
for (String definition : List.of("weight_result_id BIGINT","block_id BIGINT","slot_no INT","allocated_package_count INT")) {
jdbc.execute("ALTER TABLE fl_inbound_pallet_allocation ADD " + definition);
}
jdbc.execute("CREATE TABLE fl_inbound_weight_result(id BIGINT,tenant_id BIGINT,inbound_id BIGINT,deleted INT,recognition_status VARCHAR)");
jdbc.execute("CREATE TABLE fl_loading_pallet_label(id BIGINT,loading_plan_id BIGINT,loading_plan_item_id BIGINT,account_id BIGINT,inbound_id BIGINT,inbound_code VARCHAR,container_no VARCHAR,destination_warehouse_code VARCHAR,destination_warehouse_name VARCHAR,pallet_index INT,pallet_code VARCHAR)");
jdbc.execute("CREATE TABLE fl_inbound_carton_label(tenant_id BIGINT,inbound_detail_id BIGINT,batch_status VARCHAR,box_code VARCHAR,carton_index INT,id BIGINT)");
}
@AfterEach void close() { session.close(); }
@Test void allPartialItemsAreValidatedBeforeSettlementWithoutWrites() {
seed(1,11,3,2,"PARTIAL","客户A");
var before = jdbc.queryForList("SELECT * FROM fl_loading_plan_item");
service.requirePublishable(90);
assertThat(jdbc.queryForList("SELECT * FROM fl_loading_plan_item")).isEqualTo(before);
verify(labels,never()).prepare(anyLong(),anyLong());
verifyNoInteractions(outbox);
}
@Test void mixedCustomerPartialCannotDisappearFromReadinessValidation() {
seed(1,11,3,3,"PICKED","客户A");
seed(2,22,3,2,"PARTIAL",null);
assertThatThrownBy(() -> service.requirePublishable(90)).hasMessageContaining("客户名称快照");
verify(labels,never()).prepare(anyLong(),anyLong());
verifyNoInteractions(outbox);
}
@Test void invalidSecondCustomersRealPartialSnapshotStopsWorkerBeforeAnySettlement() {
seed(1,11,3,3,"PICKED","客户A");
seed(2,22,3,2,"PARTIAL",null);
var worker = new LoadingPickService();
var repository = mock(com.baiyue.firstleg.repository.LoadingPickRepository.class);
var settlements = mock(PdaPickSettlementService.class);
var list = new LoadingListService();
ReflectionTestUtils.setField(list,"loadingCompletedOutboxService",service);
ReflectionTestUtils.setField(worker,"repository",repository);
ReflectionTestUtils.setField(worker,"settlementService",settlements);
ReflectionTestUtils.setField(worker,"loadingListService",list);
ReflectionTestUtils.setField(worker,"inboundHoldGuard",mock(InboundHoldGuard.class));
ReflectionTestUtils.setField(worker,"cabinetEvidenceService",mock(com.baiyue.firstleg.loading.service.CabinetLoadingEvidenceService.class));
ReflectionTestUtils.setField(worker,"coloadGroups",mock(com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort.class));
var task = new com.baiyue.firstleg.entity.LoadingPickTaskEntity();
task.setId(92L); task.setLoadingPlanId(90L); task.setContainerBookingId(91L);
when(repository.task(92L)).thenReturn(task);
when(repository.inboundOwners(92L)).thenReturn(List.of(
new com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundOwner(11L,1L),
new com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundOwner(22L,2L)));
var command = new com.baiyue.firstleg.entity.LoadingCompletionCommandEntity();
command.setPickTaskId(92L);
assertThatThrownBy(() -> worker.processCompletion(command,"test"))
.isInstanceOf(LoadingPickService.CompletionProcessingException.class)
.satisfies(error -> assertThat(((LoadingPickService.CompletionProcessingException)error).stage())
.isEqualTo("COMPLETED_EVENT_READY"));
verifyNoInteractions(settlements,outbox);
}
@Test void previewUsesFrozenUnitsAndPrecisionButDoesNotScaleSettledRowsTwice() {
seed(1,11,3,2,"PARTIAL","客户A");
Row row = snapshots.previewRows(90).get(0);
LoadingCompletedOutboxService.projectSettledRow(row);
assertThat(row.getPackageCount()).isEqualTo(2);
assertThat(row.getProductQuantity()).isEqualTo(14);
assertThat(row.getGrossWeightKg()).isEqualByComparingTo("6.667");
assertThat(row.getNetWeightKg()).isEqualByComparingTo("5.335");
assertThat(row.getDimensionalWeightKg()).isEqualByComparingTo("4.003");
assertThat(row.getVolumeCbm()).isEqualByComparingTo("0.0667");
assertThat(row.getPalletCount()).isEqualTo(2);
LoadingCompletedOutboxService.projectSettledRow(row);
assertThat(row.getProductQuantity()).isEqualTo(14);
assertThat(row.getGrossWeightKg()).isEqualByComparingTo("6.667");
assertThat(jdbc.queryForObject("SELECT package_count FROM fl_loading_plan_item",Integer.class)).isEqualTo(3);
}
@Test void previewUsesHalfUpFallbackAndKeepsUnknownMetricsNull() {
seed(1,11,3,2,"PARTIAL","客户A");
jdbc.update("UPDATE fl_loading_plan_item SET units_per_carton=NULL,product_quantity=10,gross_weight_kg=NULL,net_weight_kg=NULL,dimensional_weight_kg=NULL,volume_cbm=NULL");
Row row = snapshots.previewRows(90).get(0);
LoadingCompletedOutboxService.projectSettledRow(row);
assertThat(row.getProductQuantity()).isEqualTo(7);
assertThat(row.getGrossWeightKg()).isNull();
assertThat(row.getNetWeightKg()).isNull();
assertThat(row.getDimensionalWeightKg()).isNull();
assertThat(row.getVolumeCbm()).isNull();
}
@Test void finalPublicationRejectsMixedUnsettledItemsInsteadOfPublishingOnlyFullOnes() {
seed(1,11,3,3,"PICKED","客户A");
seed(2,22,3,2,"PARTIAL","客户B");
assertThat(snapshots.rows(90)).hasSize(1);
assertThatThrownBy(() -> service.publish(90)).hasMessageContaining("尚未完成结算");
verifyNoInteractions(labels,outbox);
}
@Test void actualSettlementSqlProducesExactlyThePredictedMetricsAndEnablesFinalEvent() throws Exception {
seed(1,11,3,2,"PARTIAL","客户A");
Row expected = snapshots.previewRows(90).get(0);
LoadingCompletedOutboxService.projectSettledRow(expected);
String sql = String.join("\n", com.baiyue.firstleg.mapper.InboundSplitSettlementMapper.class
.getMethod("convergePartialPlanItem",long.class,long.class,String.class)
.getAnnotation(org.apache.ibatis.annotations.Update.class).value());
String update = ("MERGE INTO fl_loading_plan_item plan_item USING fl_loading_pick_item pick_item "
+ "ON pick_item.loading_plan_item_id=plan_item.id WHEN MATCHED AND (" + sql.substring(sql.indexOf("WHERE ") + 6)
+ ") THEN UPDATE SET " + sql.substring(sql.indexOf("SET ") + 4,sql.indexOf("WHERE ")))
.replace("#{operatorId}","9").replace("#{operatorName}","'TEST'").replace("#{planItemId}","1");
assertThat(jdbc.update(update)).isEqualTo(1);
session.clearCache();
Row settled = snapshots.rows(90).get(0);
assertThat(settled).usingRecursiveComparison().isEqualTo(expected);
service.publish(90);
var published = org.mockito.ArgumentCaptor.forClass(com.baiyue.firstleg.entity.InboundOutboxEntity.class);
verify(outbox).insert(published.capture());
var json = new ObjectMapper().readTree(published.getValue().getPayloadJson());
assertThat(json.at("/payload/orders/0/packageCount").asInt()).isEqualTo(2);
assertThat(json.at("/payload/orders/0/productQuantity").asInt()).isEqualTo(14);
assertThat(json.at("/payload/orders/0/grossWeightKg").decimalValue()).isEqualByComparingTo("6.667");
}
@Test void palletPreviewUsesActualScopeAndMatchesSettledFallbackWithoutWritingLabels() {
seed(1,11,3,2,"PARTIAL","客户A");
seed(2,22,3,3,"SKIPPED","客户B");
jdbc.update("UPDATE fl_loading_pick_item SET picked_package_count=0 WHERE loading_plan_item_id=2");
jdbc.update("UPDATE fl_loading_plan_item SET pallet_count=NULL WHERE id=1");
jdbc.update("INSERT INTO fl_inbound_weight_result VALUES(1,11,1,0,'CONFIRMED'),(2,11,1,0,'CONFIRMED')");
var labelMapper = session.getMapper(LoadingPalletLabelMapper.class);
var before = jdbc.queryForList("SELECT * FROM fl_loading_plan_item ORDER BY id");
var projected = labelMapper.previewSources(90);
assertThat(projected).singleElement().satisfies(row -> {
assertThat(row.getItemId()).isEqualTo(1L);
assertThat(row.getPalletCount()).isEqualTo(2);
});
assertThat(jdbc.queryForList("SELECT * FROM fl_loading_plan_item ORDER BY id")).isEqualTo(before);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_loading_pallet_label",Integer.class)).isZero();
jdbc.update("UPDATE fl_loading_plan_item SET package_count=2,pallet_count=0 WHERE id=1");
jdbc.update("UPDATE fl_loading_plan_item SET deleted=1 WHERE id=2");
jdbc.update("UPDATE fl_inbound_order SET pallet_count=0 WHERE id=1");
session.clearCache();
assertThat(labelMapper.lockSources(90)).usingRecursiveComparison().isEqualTo(projected);
}
@Test void fallbackPalletPredictionPreservesOriginalInboundDenominatorAfterPartialSettlement() {
seed(1,11,3,1,"PARTIAL","客户A");
jdbc.update("UPDATE fl_loading_plan_item SET pallet_count=NULL WHERE id=1");
jdbc.update("INSERT INTO fl_inbound_weight_result VALUES(1,11,1,0,'CONFIRMED'),(2,11,1,0,'CONFIRMED'),(3,11,1,0,'CONFIRMED')");
var labelMapper = session.getMapper(LoadingPalletLabelMapper.class);
var projected = labelMapper.previewSources(90);
assertThat(projected).singleElement().satisfies(row -> assertThat(row.getPalletCount()).isEqualTo(1));
// Production settlement changes package_count and resets pallet_count;
// the original declaration total_package_count remains three.
jdbc.update("UPDATE fl_loading_plan_item SET package_count=1,pallet_count=0 WHERE id=1");
jdbc.update("UPDATE fl_inbound_order SET pallet_count=0 WHERE id=1");
session.clearCache();
assertThat(labelMapper.lockSources(90)).usingRecursiveComparison().isEqualTo(projected);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_loading_pallet_label",Integer.class)).isZero();
}
private void seed(long id,long account,int planned,int picked,String status,String customer) {
jdbc.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,order_no,inbound_code,business_type,fulfillment_type,destination_type,destination_warehouse_id,destination_code,destination_name,deleted) VALUES(?,?,?,'FL-TEST','IN-TEST','FBA','STANDARD','FBA',7,'ABE8','TEST',0)",id,account,id);
jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,account_id,inbound_id,inbound_detail_id,customer_name,sku_code,product_name_zh,package_count,product_quantity,units_per_carton,pallet_count,gross_weight_kg,net_weight_kg,dimensional_weight_kg,volume_cbm,deleted,unit_type) VALUES(?,90,?,?,? ,?,'SKU-TEST','TEST',?,21,7,2,10.001,8.002,6.004,0.1001,0,'PACKAGE')",id,account,id,id,customer,planned);
jdbc.update("INSERT INTO fl_loading_pick_item VALUES(?,?,92,?,?,?)",id,id,status,picked,planned);
jdbc.update("UPDATE fl_loading_plan_item SET inbound_code='IN-TEST',destination_warehouse_id=7,destination_warehouse_code='ABE8',destination_warehouse_name='TEST',load_sequence=? WHERE id=?",id,id);
jdbc.update("UPDATE fl_inbound_order SET total_package_count=?,pallet_count=2 WHERE id=?",planned,id);
}
private static CabinetLoadingEvidenceEntity photo(String type) {
var value = new CabinetLoadingEvidenceEntity(); value.setEvidenceType(type); value.setFileId(1L);
value.setFileName(type+".png"); value.setStatus("ACTIVE"); value.setActiveFlag(1); return value;
}
}
@@ -15,6 +15,29 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class LoadingPalletLabelServiceTest {
@Test
void previewChecksDestinationAndExistingCodesWithoutLocksIdsOrWrites() {
var mapper = mock(LoadingPalletLabelMapper.class);
var service = new LoadingPalletLabelService();
var warehouses = mock(WarehouseMasterDataClient.class);
ReflectionTestUtils.setField(service,"mapper",mapper);
ReflectionTestUtils.setField(service,"warehouseClient",warehouses);
ReflectionTestUtils.setField(service,"internalToken","test-token");
when(mapper.previewSources(10L)).thenReturn(List.of(source(3,"LAX9",1,"STANDARD")));
when(warehouses.active("test-token",0L,101L)).thenReturn(warehouse("LAX9","154"));
var preview = service.preview(10L,20L);
assertThat(preview).singleElement().satisfies(row -> {
assertThat(row.getPalletCode()).isEqualTo("D0002-154-001");
assertThat(row.getLabelId()).isNull();
});
org.mockito.Mockito.verify(mapper,org.mockito.Mockito.never()).lockSources(org.mockito.ArgumentMatchers.anyLong());
org.mockito.Mockito.verify(mapper,org.mockito.Mockito.never()).insert(any());
var wrong = new LoadingPalletLabelMapper.LabelRow(); wrong.setItemId(99L); wrong.setPalletCode("D0002-154-001");
when(mapper.labels(10L)).thenReturn(List.of(wrong));
org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.preview(10L,20L))
.hasMessageContaining("当前装柜明细不一致");
}
@Test
void printsAllAndWarehouseSubsetThroughSharedFbaRendererWithoutRegeneratingCodes() {
var service = org.mockito.Mockito.spy(new LoadingPalletLabelService());