fix(firstleg): settle and validate actual partial loading cargo
This commit is contained in:
+29
@@ -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);
|
||||
|
||||
|
||||
+17
-2
@@ -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,
|
||||
|
||||
+47
-6
@@ -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
|
||||
|
||||
+135
@@ -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);
|
||||
}
|
||||
}
|
||||
+103
@@ -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); }
|
||||
}
|
||||
+33
-1
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
+249
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user