fix: enforce stable shadow snapshot boundaries

This commit is contained in:
zgw
2026-09-03 15:07:37 +08:00
parent 935d7af43d
commit d77fac1d56
12 changed files with 256 additions and 88 deletions

View File

@@ -379,7 +379,9 @@ public final class JdbcWarehouseRepository implements WarehouseRepository {
+ "ORDER BY lane_code, depth, space_id", parameters, rawSpaceMapper()); + "ORDER BY lane_code, depth, space_id", parameters, rawSpaceMapper());
List<InventoryItem> inventory = named.query("SELECT i.stock_id, i.space_id, i.tray_code, i.project_no, " List<InventoryItem> inventory = named.query("SELECT i.stock_id, i.space_id, i.tray_code, i.project_no, "
+ "i.product_no, i.lot_no, i.inbound_time_raw, i.inbound_at, i.quantity, i.out_quantity " + "i.product_no, i.lot_no, i.inbound_time_raw, i.inbound_at, i.quantity, i.out_quantity "
+ "FROM " + dialect.inventoryView() + " i JOIN " + dialect.spaceStateView() + " s ON s.space_id = i.space_id " + "FROM " + dialect.inventoryView() + " i JOIN " + dialect.spaceStateView()
+ " s ON s.warehouse_id = i.warehouse_id AND s.snapshot_generation = i.snapshot_generation "
+ "AND s.space_id = i.space_id "
+ "WHERE s.warehouse_id = :warehouse AND s.lane_code IN (:lanes) " + "WHERE s.warehouse_id = :warehouse AND s.lane_code IN (:lanes) "
+ "ORDER BY i.space_id, i.stock_id", parameters, inventoryMapper()); + "ORDER BY i.space_id, i.stock_id", parameters, inventoryMapper());
Map<String, List<InventoryItem>> inventoryBySpace = inventory.stream() Map<String, List<InventoryItem>> inventoryBySpace = inventory.stream()
@@ -474,7 +476,9 @@ public final class JdbcWarehouseRepository implements WarehouseRepository {
.addValue("product", blankToNull(search.productNo())); .addValue("product", blankToNull(search.productNo()));
String sql = "SELECT i.stock_id, i.space_id, s.lane_code, s.floor_no, i.tray_code, i.project_no, " String sql = "SELECT i.stock_id, i.space_id, s.lane_code, s.floor_no, i.tray_code, i.project_no, "
+ "i.product_no, i.lot_no, i.inbound_time_raw, i.inbound_at FROM " + dialect.inventoryView() + "i.product_no, i.lot_no, i.inbound_time_raw, i.inbound_at FROM " + dialect.inventoryView()
+ " i JOIN " + dialect.spaceStateView() + " s ON s.space_id = i.space_id " + " i JOIN " + dialect.spaceStateView()
+ " s ON s.warehouse_id = i.warehouse_id AND s.snapshot_generation = i.snapshot_generation "
+ "AND s.space_id = i.space_id "
+ "WHERE s.warehouse_id = :warehouse AND " + predicate + "WHERE s.warehouse_id = :warehouse AND " + predicate
+ " ORDER BY i.inbound_at, i.inbound_time_raw, i.stock_id"; + " ORDER BY i.inbound_at, i.inbound_time_raw, i.stock_id";
List<OutboundStockMatch> matches = named.query(sql, parameters, (rs, row) -> new OutboundStockMatch( List<OutboundStockMatch> matches = named.query(sql, parameters, (rs, row) -> new OutboundStockMatch(
@@ -503,7 +507,9 @@ public final class JdbcWarehouseRepository implements WarehouseRepository {
+ "WHEN s.stock_tray_count = 0 THEN NULLIF(LTRIM(RTRIM(s.space_tray_code)), '') END"; + "WHEN s.stock_tray_count = 0 THEN NULLIF(LTRIM(RTRIM(s.space_tray_code)), '') END";
Integer count = named.queryForObject("SELECT COUNT(DISTINCT " + usableTray + ") FROM " Integer count = named.queryForObject("SELECT COUNT(DISTINCT " + usableTray + ") FROM "
+ dialect.spaceStateView() + " s JOIN " + dialect.inventoryView() + dialect.spaceStateView() + " s JOIN " + dialect.inventoryView()
+ " i ON i.space_id = s.space_id WHERE s.warehouse_id = :warehouse " + " i ON i.warehouse_id = s.warehouse_id "
+ "AND i.snapshot_generation = s.snapshot_generation AND i.space_id = s.space_id "
+ "WHERE s.warehouse_id = :warehouse "
+ "AND NULLIF(LTRIM(RTRIM(i.project_no)), '') = :project AND s.space_status = '2' " + "AND NULLIF(LTRIM(RTRIM(i.project_no)), '') = :project AND s.space_status = '2' "
+ "AND s.role = 'STORAGE' AND s.depth IS NOT NULL AND s.pending_outbound = " + "AND s.role = 'STORAGE' AND s.depth IS NOT NULL AND s.pending_outbound = "
+ dialect.falseLiteral() + " AND s.reserved = " + dialect.falseLiteral() + dialect.falseLiteral() + " AND s.reserved = " + dialect.falseLiteral()

View File

@@ -966,9 +966,9 @@ BEGIN
+ (SELECT COUNT_BIG(*) FROM dbo.in_stockin_detail inbound WITH (NOLOCK) + (SELECT COUNT_BIG(*) FROM dbo.in_stockin_detail inbound WITH (NOLOCK)
JOIN dbo.mydo_job_detail detail WITH (NOLOCK) ON detail.stockin_detail_id = inbound.stockin_detail_id JOIN dbo.mydo_job_detail detail WITH (NOLOCK) ON detail.stockin_detail_id = inbound.stockin_detail_id
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
WHERE job.warehouse_id = @warehouse_id) WHERE job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '')
+ (SELECT COUNT_BIG(*) FROM dbo.out_stockout_detail outbound WITH (NOLOCK) + (SELECT COUNT_BIG(*) FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
WHERE outbound.warehouse_id = @warehouse_id); WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0));
SELECT @source_payload = ISNULL(CONVERT(nvarchar(max), ( SELECT @source_payload = ISNULL(CONVERT(nvarchar(max), (
SELECT cargo.cargo_space_id AS [@space_id], cargo.warehouse_id AS [@warehouse_id], SELECT cargo.cargo_space_id AS [@space_id], cargo.warehouse_id AS [@warehouse_id],
@@ -1028,22 +1028,27 @@ BEGIN
inbound.stockin_id AS [@stockin_id], inbound.lot_no AS [@lot_no], inbound.stockin_id AS [@stockin_id], inbound.lot_no AS [@lot_no],
inbound.product_id AS [@product_id], inbound.quantity AS [@quantity], inbound.product_id AS [@product_id], inbound.quantity AS [@quantity],
inbound.assign_num AS [@assign_num], inbound.spare4 AS [@project_no], inbound.assign_num AS [@assign_num], inbound.spare4 AS [@project_no],
job.warehouse_id AS [@warehouse_id], detail.job_id AS [@job_id] job.warehouse_id AS [@warehouse_id], job.task_no AS [@task_no],
job.tray_code AS [@tray_code], detail.job_id AS [@job_id],
detail.in_or_out_id AS [@request_id]
FROM dbo.in_stockin_detail inbound WITH (NOLOCK) FROM dbo.in_stockin_detail inbound WITH (NOLOCK)
JOIN dbo.mydo_job_detail detail WITH (NOLOCK) ON detail.stockin_detail_id = inbound.stockin_detail_id JOIN dbo.mydo_job_detail detail WITH (NOLOCK) ON detail.stockin_detail_id = inbound.stockin_detail_id
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
WHERE job.warehouse_id = @warehouse_id WHERE job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = ''
ORDER BY inbound.stockin_detail_id ORDER BY inbound.stockin_detail_id
FOR XML PATH('inbound'), ROOT('inbound_requests'), TYPE FOR XML PATH('inbound'), ROOT('inbound_requests'), TYPE
)), ''); )), '');
SET @source_payload = @source_payload + '|OUTBOUND|' + ISNULL(CONVERT(nvarchar(max), ( SET @source_payload = @source_payload + '|OUTBOUND|' + ISNULL(CONVERT(nvarchar(max), (
SELECT outbound.stockout_detail_id AS [@stockout_detail_id], outbound.warehouse_id AS [@warehouse_id], SELECT outbound.stockout_detail_id AS [@stockout_detail_id], outbound.warehouse_id AS [@warehouse_id],
warehouse.project_no AS [@project_no],
outbound.product_id AS [@product_id], outbound.lot_no AS [@lot_no], outbound.product_id AS [@product_id], outbound.lot_no AS [@lot_no],
outbound.quantity AS [@quantity], outbound.assign_num AS [@assign_num], outbound.quantity AS [@quantity], outbound.assign_num AS [@assign_num],
outbound.allot_quantity AS [@allot_quantity], outbound.create_time AS [@create_time] outbound.allot_quantity AS [@allot_quantity], outbound.create_time AS [@create_time]
FROM dbo.out_stockout_detail outbound WITH (NOLOCK) FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
WHERE outbound.warehouse_id = @warehouse_id LEFT JOIN dbo.bas_warehouse warehouse WITH (NOLOCK)
ON warehouse.warehouse_id = outbound.warehouse_id
WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)
ORDER BY outbound.stockout_detail_id ORDER BY outbound.stockout_detail_id
FOR XML PATH('outbound'), ROOT('outbound_requests'), TYPE FOR XML PATH('outbound'), ROOT('outbound_requests'), TYPE
)), ''); )), '');
@@ -1096,22 +1101,22 @@ BEGIN
AND (job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = ''); AND (job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '');
;WITH inbound_source AS ( ;WITH inbound_source AS (
SELECT job.warehouse_id, job.task_no, job.tray_code, SELECT @warehouse_id AS warehouse_id, job.task_no, job.tray_code,
detail.in_or_out_id AS request_id, job.warehouse_id AS raw_warehouse_id, detail.in_or_out_id AS request_id, job.warehouse_id AS raw_warehouse_id,
inbound.spare4 AS project_no, inbound.product_id, inbound.lot_no, inbound.spare4 AS project_no, inbound.product_id, inbound.lot_no,
ROW_NUMBER() OVER ( ROW_NUMBER() OVER (
PARTITION BY job.warehouse_id, job.task_no, job.tray_code PARTITION BY job.task_no, job.tray_code
ORDER BY inbound.stockin_detail_id, detail.job_detail_id) AS source_rank ORDER BY inbound.stockin_detail_id, detail.job_detail_id) AS source_rank
FROM dbo.in_stockin_detail inbound WITH (NOLOCK) FROM dbo.in_stockin_detail inbound WITH (NOLOCK)
JOIN dbo.mydo_job_detail detail WITH (NOLOCK) JOIN dbo.mydo_job_detail detail WITH (NOLOCK)
ON detail.stockin_detail_id = inbound.stockin_detail_id ON detail.stockin_detail_id = inbound.stockin_detail_id
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
WHERE job.warehouse_id = @warehouse_id WHERE job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = ''
) )
INSERT INTO alg.shadow_inbound_request_snapshot ( INSERT INTO alg.shadow_inbound_request_snapshot (
warehouse_id, task_no, tray_no, request_id, raw_warehouse_id, warehouse_id, task_no, tray_no, request_id, raw_warehouse_id,
project_no, product_id, lot_no, snapshot_generation, synchronized_at) project_no, product_id, lot_no, snapshot_generation, synchronized_at)
SELECT warehouse_id, ISNULL(task_no, ''), ISNULL(tray_code, ''), request_id, SELECT @warehouse_id, ISNULL(task_no, ''), ISNULL(tray_code, ''), request_id,
raw_warehouse_id, project_no, product_id, lot_no, raw_warehouse_id, project_no, product_id, lot_no,
@snapshot_generation, @synchronized_at @snapshot_generation, @synchronized_at
FROM inbound_source FROM inbound_source
@@ -1120,11 +1125,14 @@ BEGIN
INSERT INTO alg.shadow_outbound_request_snapshot ( INSERT INTO alg.shadow_outbound_request_snapshot (
warehouse_id, stockout_detail_id, raw_warehouse_id, project_no, product_id, warehouse_id, stockout_detail_id, raw_warehouse_id, project_no, product_id,
requested_quantity, snapshot_generation, synchronized_at) requested_quantity, snapshot_generation, synchronized_at)
SELECT outbound.warehouse_id, outbound.stockout_detail_id, outbound.warehouse_id, SELECT @warehouse_id, outbound.stockout_detail_id, outbound.warehouse_id,
NULL, outbound.product_id, CONVERT(decimal(38,6), outbound.quantity), NULLIF(LTRIM(RTRIM(warehouse.project_no)), ''), outbound.product_id,
CONVERT(decimal(38,6), outbound.quantity),
@snapshot_generation, @synchronized_at @snapshot_generation, @synchronized_at
FROM dbo.out_stockout_detail outbound WITH (NOLOCK) FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
WHERE outbound.warehouse_id = @warehouse_id; LEFT JOIN dbo.bas_warehouse warehouse WITH (NOLOCK)
ON warehouse.warehouse_id = outbound.warehouse_id
WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0);
EXEC alg.usp_rebuild_space_topology @warehouse_id = @warehouse_id, EXEC alg.usp_rebuild_space_topology @warehouse_id = @warehouse_id,
@snapshot_generation = @snapshot_generation; @snapshot_generation = @snapshot_generation;
@@ -1145,9 +1153,9 @@ BEGIN
+ (SELECT COUNT_BIG(*) FROM dbo.in_stockin_detail inbound WITH (NOLOCK) + (SELECT COUNT_BIG(*) FROM dbo.in_stockin_detail inbound WITH (NOLOCK)
JOIN dbo.mydo_job_detail detail WITH (NOLOCK) ON detail.stockin_detail_id = inbound.stockin_detail_id JOIN dbo.mydo_job_detail detail WITH (NOLOCK) ON detail.stockin_detail_id = inbound.stockin_detail_id
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
WHERE job.warehouse_id = @warehouse_id) WHERE job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '')
+ (SELECT COUNT_BIG(*) FROM dbo.out_stockout_detail outbound WITH (NOLOCK) + (SELECT COUNT_BIG(*) FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
WHERE outbound.warehouse_id = @warehouse_id); WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0));
SELECT @source_payload = ISNULL(CONVERT(nvarchar(max), ( SELECT @source_payload = ISNULL(CONVERT(nvarchar(max), (
SELECT cargo.cargo_space_id AS [@space_id], cargo.warehouse_id AS [@warehouse_id], SELECT cargo.cargo_space_id AS [@space_id], cargo.warehouse_id AS [@warehouse_id],
@@ -1203,21 +1211,26 @@ BEGIN
inbound.stockin_id AS [@stockin_id], inbound.lot_no AS [@lot_no], inbound.stockin_id AS [@stockin_id], inbound.lot_no AS [@lot_no],
inbound.product_id AS [@product_id], inbound.quantity AS [@quantity], inbound.product_id AS [@product_id], inbound.quantity AS [@quantity],
inbound.assign_num AS [@assign_num], inbound.spare4 AS [@project_no], inbound.assign_num AS [@assign_num], inbound.spare4 AS [@project_no],
job.warehouse_id AS [@warehouse_id], detail.job_id AS [@job_id] job.warehouse_id AS [@warehouse_id], job.task_no AS [@task_no],
job.tray_code AS [@tray_code], detail.job_id AS [@job_id],
detail.in_or_out_id AS [@request_id]
FROM dbo.in_stockin_detail inbound WITH (NOLOCK) FROM dbo.in_stockin_detail inbound WITH (NOLOCK)
JOIN dbo.mydo_job_detail detail WITH (NOLOCK) ON detail.stockin_detail_id = inbound.stockin_detail_id JOIN dbo.mydo_job_detail detail WITH (NOLOCK) ON detail.stockin_detail_id = inbound.stockin_detail_id
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
WHERE job.warehouse_id = @warehouse_id WHERE job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = ''
ORDER BY inbound.stockin_detail_id ORDER BY inbound.stockin_detail_id
FOR XML PATH('inbound'), ROOT('inbound_requests'), TYPE FOR XML PATH('inbound'), ROOT('inbound_requests'), TYPE
)), ''); )), '');
SET @source_payload = @source_payload + '|OUTBOUND|' + ISNULL(CONVERT(nvarchar(max), ( SET @source_payload = @source_payload + '|OUTBOUND|' + ISNULL(CONVERT(nvarchar(max), (
SELECT outbound.stockout_detail_id AS [@stockout_detail_id], outbound.warehouse_id AS [@warehouse_id], SELECT outbound.stockout_detail_id AS [@stockout_detail_id], outbound.warehouse_id AS [@warehouse_id],
warehouse.project_no AS [@project_no],
outbound.product_id AS [@product_id], outbound.lot_no AS [@lot_no], outbound.product_id AS [@product_id], outbound.lot_no AS [@lot_no],
outbound.quantity AS [@quantity], outbound.assign_num AS [@assign_num], outbound.quantity AS [@quantity], outbound.assign_num AS [@assign_num],
outbound.allot_quantity AS [@allot_quantity], outbound.create_time AS [@create_time] outbound.allot_quantity AS [@allot_quantity], outbound.create_time AS [@create_time]
FROM dbo.out_stockout_detail outbound WITH (NOLOCK) FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
WHERE outbound.warehouse_id = @warehouse_id LEFT JOIN dbo.bas_warehouse warehouse WITH (NOLOCK)
ON warehouse.warehouse_id = outbound.warehouse_id
WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)
ORDER BY outbound.stockout_detail_id ORDER BY outbound.stockout_detail_id
FOR XML PATH('outbound'), ROOT('outbound_requests'), TYPE FOR XML PATH('outbound'), ROOT('outbound_requests'), TYPE
)), ''); )), '');

View File

@@ -304,9 +304,9 @@ BEGIN
JOIN dbo.mydo_job_detail detail WITH (NOLOCK) JOIN dbo.mydo_job_detail detail WITH (NOLOCK)
ON detail.stockin_detail_id = inbound.stockin_detail_id ON detail.stockin_detail_id = inbound.stockin_detail_id
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
WHERE job.warehouse_id = @warehouse_id) WHERE job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '')
+ (SELECT COUNT_BIG(*) FROM dbo.out_stockout_detail outbound WITH (NOLOCK) + (SELECT COUNT_BIG(*) FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
WHERE outbound.warehouse_id = @warehouse_id); WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0));
SELECT @source_payload = ISNULL(CONVERT(nvarchar(max), ( SELECT @source_payload = ISNULL(CONVERT(nvarchar(max), (
SELECT cargo.cargo_space_id AS [@space_id], cargo.warehouse_id AS [@warehouse_id], SELECT cargo.cargo_space_id AS [@space_id], cargo.warehouse_id AS [@warehouse_id],
@@ -363,22 +363,27 @@ BEGIN
inbound.stockin_id AS [@stockin_id], inbound.lot_no AS [@lot_no], inbound.stockin_id AS [@stockin_id], inbound.lot_no AS [@lot_no],
inbound.product_id AS [@product_id], inbound.quantity AS [@quantity], inbound.product_id AS [@product_id], inbound.quantity AS [@quantity],
inbound.assign_num AS [@assign_num], inbound.spare4 AS [@project_no], inbound.assign_num AS [@assign_num], inbound.spare4 AS [@project_no],
job.warehouse_id AS [@warehouse_id], detail.job_id AS [@job_id] job.warehouse_id AS [@warehouse_id], job.task_no AS [@task_no],
job.tray_code AS [@tray_code], detail.job_id AS [@job_id],
detail.in_or_out_id AS [@request_id]
FROM dbo.in_stockin_detail inbound WITH (NOLOCK) FROM dbo.in_stockin_detail inbound WITH (NOLOCK)
JOIN dbo.mydo_job_detail detail WITH (NOLOCK) JOIN dbo.mydo_job_detail detail WITH (NOLOCK)
ON detail.stockin_detail_id = inbound.stockin_detail_id ON detail.stockin_detail_id = inbound.stockin_detail_id
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
WHERE job.warehouse_id = @warehouse_id WHERE job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = ''
ORDER BY inbound.stockin_detail_id ORDER BY inbound.stockin_detail_id
FOR XML PATH('inbound'), ROOT('inbound_requests'), TYPE FOR XML PATH('inbound'), ROOT('inbound_requests'), TYPE
)), ''); )), '');
SET @source_payload = @source_payload + '|OUTBOUND|' + ISNULL(CONVERT(nvarchar(max), ( SET @source_payload = @source_payload + '|OUTBOUND|' + ISNULL(CONVERT(nvarchar(max), (
SELECT outbound.stockout_detail_id AS [@stockout_detail_id], outbound.warehouse_id AS [@warehouse_id], SELECT outbound.stockout_detail_id AS [@stockout_detail_id], outbound.warehouse_id AS [@warehouse_id],
warehouse.project_no AS [@project_no],
outbound.product_id AS [@product_id], outbound.lot_no AS [@lot_no], outbound.product_id AS [@product_id], outbound.lot_no AS [@lot_no],
outbound.quantity AS [@quantity], outbound.assign_num AS [@assign_num], outbound.quantity AS [@quantity], outbound.assign_num AS [@assign_num],
outbound.allot_quantity AS [@allot_quantity], outbound.create_time AS [@create_time] outbound.allot_quantity AS [@allot_quantity], outbound.create_time AS [@create_time]
FROM dbo.out_stockout_detail outbound WITH (NOLOCK) FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
WHERE outbound.warehouse_id = @warehouse_id LEFT JOIN dbo.bas_warehouse warehouse WITH (NOLOCK)
ON warehouse.warehouse_id = outbound.warehouse_id
WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)
ORDER BY outbound.stockout_detail_id ORDER BY outbound.stockout_detail_id
FOR XML PATH('outbound'), ROOT('outbound_requests'), TYPE FOR XML PATH('outbound'), ROOT('outbound_requests'), TYPE
)), ''); )), '');
@@ -430,22 +435,22 @@ BEGIN
AND (job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = ''); AND (job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '');
;WITH inbound_source AS ( ;WITH inbound_source AS (
SELECT job.warehouse_id, job.task_no, job.tray_code, SELECT @warehouse_id AS warehouse_id, job.task_no, job.tray_code,
detail.in_or_out_id AS request_id, job.warehouse_id AS raw_warehouse_id, detail.in_or_out_id AS request_id, job.warehouse_id AS raw_warehouse_id,
inbound.spare4 AS project_no, inbound.product_id, inbound.lot_no, inbound.spare4 AS project_no, inbound.product_id, inbound.lot_no,
ROW_NUMBER() OVER ( ROW_NUMBER() OVER (
PARTITION BY job.warehouse_id, job.task_no, job.tray_code PARTITION BY job.task_no, job.tray_code
ORDER BY inbound.stockin_detail_id, detail.job_detail_id) AS source_rank ORDER BY inbound.stockin_detail_id, detail.job_detail_id) AS source_rank
FROM dbo.in_stockin_detail inbound WITH (NOLOCK) FROM dbo.in_stockin_detail inbound WITH (NOLOCK)
JOIN dbo.mydo_job_detail detail WITH (NOLOCK) JOIN dbo.mydo_job_detail detail WITH (NOLOCK)
ON detail.stockin_detail_id = inbound.stockin_detail_id ON detail.stockin_detail_id = inbound.stockin_detail_id
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
WHERE job.warehouse_id = @warehouse_id WHERE job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = ''
) )
INSERT INTO alg.shadow_inbound_request_snapshot ( INSERT INTO alg.shadow_inbound_request_snapshot (
warehouse_id, task_no, tray_no, request_id, raw_warehouse_id, warehouse_id, task_no, tray_no, request_id, raw_warehouse_id,
project_no, product_id, lot_no, snapshot_generation, synchronized_at) project_no, product_id, lot_no, snapshot_generation, synchronized_at)
SELECT warehouse_id, ISNULL(task_no, ''), ISNULL(tray_code, ''), request_id, SELECT @warehouse_id, ISNULL(task_no, ''), ISNULL(tray_code, ''), request_id,
raw_warehouse_id, project_no, product_id, lot_no, raw_warehouse_id, project_no, product_id, lot_no,
@snapshot_generation, @synchronized_at @snapshot_generation, @synchronized_at
FROM inbound_source FROM inbound_source
@@ -454,11 +459,14 @@ BEGIN
INSERT INTO alg.shadow_outbound_request_snapshot ( INSERT INTO alg.shadow_outbound_request_snapshot (
warehouse_id, stockout_detail_id, raw_warehouse_id, project_no, product_id, warehouse_id, stockout_detail_id, raw_warehouse_id, project_no, product_id,
requested_quantity, snapshot_generation, synchronized_at) requested_quantity, snapshot_generation, synchronized_at)
SELECT outbound.warehouse_id, outbound.stockout_detail_id, outbound.warehouse_id, SELECT @warehouse_id, outbound.stockout_detail_id, outbound.warehouse_id,
NULL, outbound.product_id, CONVERT(decimal(38,6), outbound.quantity), NULLIF(LTRIM(RTRIM(warehouse.project_no)), ''), outbound.product_id,
CONVERT(decimal(38,6), outbound.quantity),
@snapshot_generation, @synchronized_at @snapshot_generation, @synchronized_at
FROM dbo.out_stockout_detail outbound WITH (NOLOCK) FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
WHERE outbound.warehouse_id = @warehouse_id; LEFT JOIN dbo.bas_warehouse warehouse WITH (NOLOCK)
ON warehouse.warehouse_id = outbound.warehouse_id
WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0);
EXEC alg.usp_rebuild_space_topology @warehouse_id = @warehouse_id, EXEC alg.usp_rebuild_space_topology @warehouse_id = @warehouse_id,
@snapshot_generation = @snapshot_generation; @snapshot_generation = @snapshot_generation;
@@ -479,8 +487,9 @@ BEGIN
+ (SELECT COUNT_BIG(*) FROM dbo.in_stockin_detail inbound WITH (NOLOCK) + (SELECT COUNT_BIG(*) FROM dbo.in_stockin_detail inbound WITH (NOLOCK)
JOIN dbo.mydo_job_detail detail WITH (NOLOCK) ON detail.stockin_detail_id = inbound.stockin_detail_id JOIN dbo.mydo_job_detail detail WITH (NOLOCK) ON detail.stockin_detail_id = inbound.stockin_detail_id
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
WHERE job.warehouse_id = @warehouse_id) WHERE job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '')
+ (SELECT COUNT_BIG(*) FROM dbo.out_stockout_detail outbound WITH (NOLOCK) WHERE outbound.warehouse_id = @warehouse_id); + (SELECT COUNT_BIG(*) FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0));
SELECT @source_payload = ISNULL(CONVERT(nvarchar(max), ( SELECT @source_payload = ISNULL(CONVERT(nvarchar(max), (
SELECT cargo.cargo_space_id AS [@space_id], cargo.warehouse_id AS [@warehouse_id], SELECT cargo.cargo_space_id AS [@space_id], cargo.warehouse_id AS [@warehouse_id],
@@ -535,20 +544,25 @@ BEGIN
SELECT inbound.stockin_detail_id AS [@stockin_detail_id], inbound.stockin_guid AS [@stockin_guid], SELECT inbound.stockin_detail_id AS [@stockin_detail_id], inbound.stockin_guid AS [@stockin_guid],
inbound.stockin_id AS [@stockin_id], inbound.lot_no AS [@lot_no], inbound.product_id AS [@product_id], inbound.stockin_id AS [@stockin_id], inbound.lot_no AS [@lot_no], inbound.product_id AS [@product_id],
inbound.quantity AS [@quantity], inbound.assign_num AS [@assign_num], inbound.spare4 AS [@project_no], inbound.quantity AS [@quantity], inbound.assign_num AS [@assign_num], inbound.spare4 AS [@project_no],
job.warehouse_id AS [@warehouse_id], detail.job_id AS [@job_id] job.warehouse_id AS [@warehouse_id], job.task_no AS [@task_no],
job.tray_code AS [@tray_code], detail.job_id AS [@job_id],
detail.in_or_out_id AS [@request_id]
FROM dbo.in_stockin_detail inbound WITH (NOLOCK) FROM dbo.in_stockin_detail inbound WITH (NOLOCK)
JOIN dbo.mydo_job_detail detail WITH (NOLOCK) ON detail.stockin_detail_id = inbound.stockin_detail_id JOIN dbo.mydo_job_detail detail WITH (NOLOCK) ON detail.stockin_detail_id = inbound.stockin_detail_id
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
WHERE job.warehouse_id = @warehouse_id WHERE job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = ''
ORDER BY inbound.stockin_detail_id ORDER BY inbound.stockin_detail_id
FOR XML PATH('inbound'), ROOT('inbound_requests'), TYPE FOR XML PATH('inbound'), ROOT('inbound_requests'), TYPE
)), ''); )), '');
SET @source_payload = @source_payload + '|OUTBOUND|' + ISNULL(CONVERT(nvarchar(max), ( SET @source_payload = @source_payload + '|OUTBOUND|' + ISNULL(CONVERT(nvarchar(max), (
SELECT outbound.stockout_detail_id AS [@stockout_detail_id], outbound.warehouse_id AS [@warehouse_id], SELECT outbound.stockout_detail_id AS [@stockout_detail_id], outbound.warehouse_id AS [@warehouse_id],
warehouse.project_no AS [@project_no],
outbound.product_id AS [@product_id], outbound.lot_no AS [@lot_no], outbound.quantity AS [@quantity], outbound.product_id AS [@product_id], outbound.lot_no AS [@lot_no], outbound.quantity AS [@quantity],
outbound.assign_num AS [@assign_num], outbound.allot_quantity AS [@allot_quantity], outbound.create_time AS [@create_time] outbound.assign_num AS [@assign_num], outbound.allot_quantity AS [@allot_quantity], outbound.create_time AS [@create_time]
FROM dbo.out_stockout_detail outbound WITH (NOLOCK) FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
WHERE outbound.warehouse_id = @warehouse_id LEFT JOIN dbo.bas_warehouse warehouse WITH (NOLOCK)
ON warehouse.warehouse_id = outbound.warehouse_id
WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)
ORDER BY outbound.stockout_detail_id ORDER BY outbound.stockout_detail_id
FOR XML PATH('outbound'), ROOT('outbound_requests'), TYPE FOR XML PATH('outbound'), ROOT('outbound_requests'), TYPE
)), ''); )), '');

View File

@@ -86,6 +86,24 @@ class JdbcWarehouseRepositoryTest {
assertThat(space(lanes, "OUT-INNER").state().zoneCode()).isEqualTo("FLEX"); assertThat(space(lanes, "OUT-INNER").state().zoneCode()).isEqualTo("FLEX");
} }
@Test
void publishedReadModelJoinsDoNotCrossWarehouseBoundaries() throws Exception {
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('SHARED-SPACE','W1','R','A','Shared id',9,90,1,'2','',"
+ "'TRAY-W1','SHARED-LANE','1')");
execute("INSERT INTO ALG_INVENTORY_ITEM_SNAPSHOT (WAREHOUSE_ID, STOCK_ID, SPACE_ID, TRAY_CODE, "
+ "PROJECT_NO, PRODUCT_NO, LOT_NO, INBOUND_TIME_RAW, INBOUND_AT, QUANTITY, OUT_QUANTITY) "
+ "VALUES ('W2','CROSS-W2','SHARED-SPACE',NULL,'PROJECT-W2','SKU-W2','LOT-W2',"
+ "'2026-09-03 00:00:00',TIMESTAMP '2026-09-03 00:00:00',1,0)");
rebuildTopology("W1");
List<LaneSnapshot> lanes = repository.loadLaneSnapshots("W1", List.of("SHARED-LANE"));
assertThat(space(lanes, "SHARED-SPACE").inventory()).isEmpty();
assertThat(repository.searchOutboundStock("W1", OutboundStockSearch.project("PROJECT-W2", null)))
.isEmpty();
assertThat(repository.countProjectPallets("W1", "PROJECT-W2")).isZero();
}
@Test @Test
void statusFourPhysicalOuterPalletIsPreservedAndMakesDeepOutboundWait() throws Exception { void statusFourPhysicalOuterPalletIsPreservedAndMakesDeepOutboundWait() throws Exception {
execute("INSERT INTO BAS_CARGO_SPACE VALUES ('ALLOC-TARGET','W1','RA','A','Target',7,21,2,'2',''," execute("INSERT INTO BAS_CARGO_SPACE VALUES ('ALLOC-TARGET','W1','RA','A','Target',7,21,2,'2','',"

View File

@@ -208,6 +208,25 @@ class ShadowSqlServerMigrationScriptTest {
"snapshot_generation = @snapshot_generation"); "snapshot_generation = @snapshot_generation");
} }
@Test
void refreshPreservesRawRequestWarehouseUnderAuthoritativeSnapshotWarehouse() throws IOException {
for (String script : new String[]{"db/sqlserver/install-shadow.sql",
"db/sqlserver/upgrade-shadow-v2-to-v3.sql"}) {
String refresh = normalize(resource(script));
refresh = refresh.substring(refresh.indexOf("create procedure alg.usp_refresh_shadow_snapshot"));
assertThat(refresh).as(script).contains(
"select @warehouse_id, isnull(task_no, ''), isnull(tray_code, ''), request_id, raw_warehouse_id",
"select @warehouse_id, outbound.stockout_detail_id, outbound.warehouse_id",
"left join dbo.bas_warehouse warehouse with (nolock)",
"nullif(ltrim(rtrim(warehouse.project_no)), '')",
"job.finish_time is null or ltrim(rtrim(job.finish_time)) = ''",
"isnull(outbound.quantity, 0) > isnull(outbound.allot_quantity, 0)");
assertThat(refresh).as(script).doesNotContain(
"select outbound.warehouse_id, outbound.stockout_detail_id, outbound.warehouse_id, null");
}
}
@Test @Test
void plannerViewsContainOnlyGenerationQualifiedAlgSnapshots() throws IOException { void plannerViewsContainOnlyGenerationQualifiedAlgSnapshots() throws IOException {
String install = normalize(resource("db/sqlserver/install-shadow.sql")); String install = normalize(resource("db/sqlserver/install-shadow.sql"));

View File

@@ -178,6 +178,7 @@ $$;
CREATE VIEW ALG_V_INVENTORY_ITEM AS CREATE VIEW ALG_V_INVENTORY_ITEM AS
SELECT SELECT
WAREHOUSE_ID, CAST(0 AS BIGINT) AS SNAPSHOT_GENERATION,
STOCK_ID, SPACE_ID, TRAY_CODE, PROJECT_NO, PRODUCT_NO, LOT_NO, STOCK_ID, SPACE_ID, TRAY_CODE, PROJECT_NO, PRODUCT_NO, LOT_NO,
INBOUND_TIME_RAW, INBOUND_AT, QUANTITY, OUT_QUANTITY INBOUND_TIME_RAW, INBOUND_AT, QUANTITY, OUT_QUANTITY
FROM ALG_INVENTORY_ITEM_SNAPSHOT; FROM ALG_INVENTORY_ITEM_SNAPSHOT;
@@ -281,6 +282,7 @@ LANE_GAPS AS (
SELECT SELECT
C.CARGO_SPACE_ID AS SPACE_ID, C.CARGO_SPACE_ID AS SPACE_ID,
C.WAREHOUSE_ID, C.WAREHOUSE_ID,
CAST(0 AS BIGINT) AS SNAPSHOT_GENERATION,
C.GROUP_CODING_NO AS LANE_CODE, C.GROUP_CODING_NO AS LANE_CODE,
C.CARGO_AREA_ID AS AREA_ID, C.CARGO_AREA_ID AS AREA_ID,
CASE CASE

View File

@@ -1,26 +1,43 @@
package com.ruoyi.algorithm.shadow; package com.ruoyi.algorithm.shadow;
import java.time.Instant;
public record InboundShadowObservation( public record InboundShadowObservation(
boolean observed, boolean observed,
long snapshotGeneration, long snapshotGeneration,
String warehouseId, String warehouseId,
Instant snapshotSynchronizedAt,
String sourceSignature,
String requestId, String requestId,
String resultCode, String resultCode,
String proposedSpaceId, String proposedSpaceId,
String reason) { String reason) {
public InboundShadowObservation(boolean observed, long snapshotGeneration, String warehouseId,
String requestId, String resultCode, String proposedSpaceId, String reason) {
this(observed, snapshotGeneration, warehouseId, null, null,
requestId, resultCode, proposedSpaceId, reason);
}
public InboundShadowObservation(boolean observed, String warehouseId, String requestId, public InboundShadowObservation(boolean observed, String warehouseId, String requestId,
String resultCode, String proposedSpaceId, String reason) { String resultCode, String proposedSpaceId, String reason) {
this(observed, 0L, warehouseId, requestId, resultCode, proposedSpaceId, reason); this(observed, 0L, warehouseId, null, null, requestId, resultCode, proposedSpaceId, reason);
} }
public static InboundShadowObservation empty() { public static InboundShadowObservation empty() {
return new InboundShadowObservation(false, 0L, null, null, "NOT_OBSERVED", null, null); return new InboundShadowObservation(false, 0L, null, null, null,
null, "NOT_OBSERVED", null, null);
} }
public static InboundShadowObservation unavailable(long snapshotGeneration, String warehouseId, public static InboundShadowObservation unavailable(long snapshotGeneration, String warehouseId,
String requestId, String resultCode, String reason) { String requestId, String resultCode, String reason) {
return new InboundShadowObservation(true, snapshotGeneration, warehouseId, requestId, return new InboundShadowObservation(true, snapshotGeneration, warehouseId, null, null,
resultCode, null, reason); requestId, resultCode, null, reason);
}
public static InboundShadowObservation unavailable(PublishedShadowSnapshot snapshot,
String requestId, String resultCode, String reason) {
return new InboundShadowObservation(true, snapshot.generation(), snapshot.warehouseId(),
snapshot.synchronizedAt(), snapshot.sourceSignature(), requestId, resultCode, null, reason);
} }
} }

View File

@@ -1,27 +1,47 @@
package com.ruoyi.algorithm.shadow; package com.ruoyi.algorithm.shadow;
import java.time.Instant;
public record OutboundShadowObservation( public record OutboundShadowObservation(
boolean observed, boolean observed,
long snapshotGeneration, long snapshotGeneration,
String warehouseId, String warehouseId,
Instant snapshotSynchronizedAt,
String sourceSignature,
String requestId, String requestId,
String resultCode, String resultCode,
String proposedStockId, String proposedStockId,
String proposedSpaceId, String proposedSpaceId,
String reason) { String reason) {
public OutboundShadowObservation(boolean observed, long snapshotGeneration, String warehouseId,
String requestId, String resultCode, String proposedStockId, String proposedSpaceId,
String reason) {
this(observed, snapshotGeneration, warehouseId, null, null,
requestId, resultCode, proposedStockId, proposedSpaceId, reason);
}
public OutboundShadowObservation(boolean observed, String warehouseId, String requestId, public OutboundShadowObservation(boolean observed, String warehouseId, String requestId,
String resultCode, String proposedStockId, String proposedSpaceId, String reason) { String resultCode, String proposedStockId, String proposedSpaceId, String reason) {
this(observed, 0L, warehouseId, requestId, resultCode, proposedStockId, proposedSpaceId, reason); this(observed, 0L, warehouseId, null, null,
requestId, resultCode, proposedStockId, proposedSpaceId, reason);
} }
public static OutboundShadowObservation empty() { public static OutboundShadowObservation empty() {
return new OutboundShadowObservation(false, 0L, null, null, "NOT_OBSERVED", null, null, null); return new OutboundShadowObservation(false, 0L, null, null, null,
null, "NOT_OBSERVED", null, null, null);
} }
public static OutboundShadowObservation unavailable(long snapshotGeneration, String warehouseId, public static OutboundShadowObservation unavailable(long snapshotGeneration, String warehouseId,
String requestId, String resultCode, String reason) { String requestId, String resultCode, String reason) {
return new OutboundShadowObservation(true, snapshotGeneration, warehouseId, requestId, return new OutboundShadowObservation(true, snapshotGeneration, warehouseId, null, null,
resultCode, null, null, reason); requestId, resultCode, null, null, reason);
}
public static OutboundShadowObservation unavailable(PublishedShadowSnapshot snapshot,
String requestId, String resultCode, String reason) {
return new OutboundShadowObservation(true, snapshot.generation(), snapshot.warehouseId(),
snapshot.synchronizedAt(), snapshot.sourceSignature(),
requestId, resultCode, null, null, reason);
} }
} }

View File

@@ -21,6 +21,7 @@ import java.util.function.Supplier;
@Component @Component
public class ShadowPlanningEngine { public class ShadowPlanningEngine {
private static final int INSTANCE_STALE_SECONDS = 120; private static final int INSTANCE_STALE_SECONDS = 120;
private static final int DECISION_REASON_MAX_LENGTH = 2000;
private static final String PUBLISHED = "PUBLISHED"; private static final String PUBLISHED = "PUBLISHED";
private final SchedulingFacade schedulingFacade; private final SchedulingFacade schedulingFacade;
@@ -81,8 +82,8 @@ public class ShadowPlanningEngine {
ShadowStockReference reference = inputMapper.selectProjectOutboundReference( ShadowStockReference reference = inputMapper.selectProjectOutboundReference(
warehouseId, snapshot.generation(), stockoutDetailId); warehouseId, snapshot.generation(), stockoutDetailId);
if (reference == null || isBlank(reference.getProjectNo())) { if (reference == null || isBlank(reference.getProjectNo())) {
return OutboundShadowObservation.unavailable(snapshot.generation(), warehouseId, return OutboundShadowObservation.unavailable(snapshot, stockoutDetailId,
stockoutDetailId, "SKIPPED_MISSING_PROJECT", "SKIPPED_MISSING_PROJECT",
outboundDiagnostic(snapshot, stockoutDetailId, reference, outboundDiagnostic(snapshot, stockoutDetailId, reference,
"No project number was available for shadow planning")); "No project number was available for shadow planning"));
} }
@@ -100,8 +101,8 @@ public class ShadowPlanningEngine {
ShadowStockReference reference = inputMapper.selectStockReference( ShadowStockReference reference = inputMapper.selectStockReference(
warehouseId, snapshot.generation(), stockId); warehouseId, snapshot.generation(), stockId);
if (reference == null || isBlank(reference.getTrayNo())) { if (reference == null || isBlank(reference.getTrayNo())) {
return OutboundShadowObservation.unavailable(snapshot.generation(), warehouseId, return OutboundShadowObservation.unavailable(snapshot, stockoutDetailId,
stockoutDetailId, "SKIPPED_MISSING_STOCK", "SKIPPED_MISSING_STOCK",
outboundDiagnostic(snapshot, stockoutDetailId, reference, outboundDiagnostic(snapshot, stockoutDetailId, reference,
"Selected stock was unavailable to shadow planning")); "Selected stock was unavailable to shadow planning"));
} }
@@ -116,37 +117,41 @@ public class ShadowPlanningEngine {
String legacySpaceId) { String legacySpaceId) {
Objects.requireNonNull(observation, "observation"); Objects.requireNonNull(observation, "observation");
transactions.executeWithoutResult(status -> { transactions.executeWithoutResult(status -> {
requirePublishedGeneration(observation.snapshotGeneration(), observation.warehouseId(), validateComparisonSnapshot(observation.snapshotGeneration(), observation.warehouseId(),
"published snapshot changed before comparison"); observation.snapshotSynchronizedAt(), observation.sourceSignature());
inputMapper.insertComparison( int inserted = inputMapper.insertComparison(
UUID.randomUUID().toString(), observation.warehouseId(), "INBOUND_COMPARE", UUID.randomUUID().toString(), observation.warehouseId(), "INBOUND_COMPARE",
observation.snapshotGeneration(), observation.snapshotGeneration(), observation.snapshotSynchronizedAt(),
observation.sourceSignature(),
comparisonCode(observation.proposedSpaceId(), legacySpaceId), comparisonCode(observation.proposedSpaceId(), legacySpaceId),
"snapshotGeneration=" + observation.snapshotGeneration() abbreviate("snapshotGeneration=" + observation.snapshotGeneration()
+ "; request=" + observation.requestId() + "; request=" + observation.requestId()
+ "; shadow=" + observation.resultCode() + "; shadow=" + observation.resultCode()
+ "; proposedSpace=" + nullSafe(observation.proposedSpaceId()) + "; proposedSpace=" + nullSafe(observation.proposedSpaceId())
+ "; shadowReason=" + nullSafe(observation.reason()) + "; shadowReason=" + nullSafe(observation.reason())
+ "; legacy=" + nullSafe(legacyResultCode) + "; legacy=" + nullSafe(legacyResultCode)
+ "; legacySpace=" + nullSafe(legacySpaceId)); + "; legacySpace=" + nullSafe(legacySpaceId), DECISION_REASON_MAX_LENGTH));
requireComparisonInserted(inserted);
}); });
} }
public void compareOutbound(OutboundShadowObservation observation, String legacyResultCode) { public void compareOutbound(OutboundShadowObservation observation, String legacyResultCode) {
Objects.requireNonNull(observation, "observation"); Objects.requireNonNull(observation, "observation");
transactions.executeWithoutResult(status -> { transactions.executeWithoutResult(status -> {
requirePublishedGeneration(observation.snapshotGeneration(), observation.warehouseId(), validateComparisonSnapshot(observation.snapshotGeneration(), observation.warehouseId(),
"published snapshot changed before comparison"); observation.snapshotSynchronizedAt(), observation.sourceSignature());
inputMapper.insertComparison( int inserted = inputMapper.insertComparison(
UUID.randomUUID().toString(), observation.warehouseId(), "OUTBOUND_COMPARE", UUID.randomUUID().toString(), observation.warehouseId(), "OUTBOUND_COMPARE",
observation.snapshotGeneration(), "LEGACY_" + nullSafe(legacyResultCode), observation.snapshotGeneration(), observation.snapshotSynchronizedAt(),
"snapshotGeneration=" + observation.snapshotGeneration() observation.sourceSignature(), "LEGACY_" + nullSafe(legacyResultCode),
abbreviate("snapshotGeneration=" + observation.snapshotGeneration()
+ "; request=" + observation.requestId() + "; request=" + observation.requestId()
+ "; shadow=" + observation.resultCode() + "; shadow=" + observation.resultCode()
+ "; proposedStock=" + nullSafe(observation.proposedStockId()) + "; proposedStock=" + nullSafe(observation.proposedStockId())
+ "; proposedSpace=" + nullSafe(observation.proposedSpaceId()) + "; proposedSpace=" + nullSafe(observation.proposedSpaceId())
+ "; shadowReason=" + nullSafe(observation.reason()) + "; shadowReason=" + nullSafe(observation.reason())
+ "; legacy=" + nullSafe(legacyResultCode)); + "; legacy=" + nullSafe(legacyResultCode), DECISION_REASON_MAX_LENGTH));
requireComparisonInserted(inserted);
}); });
} }
@@ -156,7 +161,7 @@ public class ShadowPlanningEngine {
ShadowInboundReference reference = inputMapper.selectInboundInput( ShadowInboundReference reference = inputMapper.selectInboundInput(
warehouseId, snapshot.generation(), taskNo, trayNo); warehouseId, snapshot.generation(), taskNo, trayNo);
if (reference == null || isBlank(reference.getRequestId()) || isBlank(reference.getTrayNo())) { if (reference == null || isBlank(reference.getRequestId()) || isBlank(reference.getTrayNo())) {
return InboundShadowObservation.unavailable(snapshot.generation(), warehouseId, taskNo, return InboundShadowObservation.unavailable(snapshot, taskNo,
"SKIPPED_MISSING_INPUT", "SKIPPED_MISSING_INPUT",
inboundDiagnostic(snapshot, taskNo, trayNo, reference, inboundDiagnostic(snapshot, taskNo, trayNo, reference,
"Inbound task details were unavailable to shadow planning")); "Inbound task details were unavailable to shadow planning"));
@@ -169,11 +174,12 @@ public class ShadowPlanningEngine {
if (plan.assignment().isPresent()) { if (plan.assignment().isPresent()) {
var assignment = plan.assignment().orElseThrow(); var assignment = plan.assignment().orElseThrow();
return new InboundShadowObservation(true, snapshot.generation(), warehouseId, return new InboundShadowObservation(true, snapshot.generation(), warehouseId,
snapshot.synchronizedAt(), snapshot.sourceSignature(),
reference.getRequestId(), "READY", assignment.spaceId(), reference.getRequestId(), "READY", assignment.spaceId(),
inboundDiagnostic(snapshot, taskNo, trayNo, reference, assignment.reason())); inboundDiagnostic(snapshot, taskNo, trayNo, reference, assignment.reason()));
} }
var rejection = plan.rejection().orElseThrow(); var rejection = plan.rejection().orElseThrow();
return InboundShadowObservation.unavailable(snapshot.generation(), warehouseId, return InboundShadowObservation.unavailable(snapshot,
reference.getRequestId(), "REJECTED_" + rejection.code().name(), reference.getRequestId(), "REJECTED_" + rejection.code().name(),
inboundDiagnostic(snapshot, taskNo, trayNo, reference, rejection.reason())); inboundDiagnostic(snapshot, taskNo, trayNo, reference, rejection.reason()));
} }
@@ -186,17 +192,18 @@ public class ShadowPlanningEngine {
if (plan.assignment().isPresent()) { if (plan.assignment().isPresent()) {
var assignment = plan.assignment().orElseThrow(); var assignment = plan.assignment().orElseThrow();
return new OutboundShadowObservation(true, snapshot.generation(), snapshot.warehouseId(), return new OutboundShadowObservation(true, snapshot.generation(), snapshot.warehouseId(),
snapshot.synchronizedAt(), snapshot.sourceSignature(),
requestId, "READY", reference.getStockId(), assignment.sourceSpaceId(), requestId, "READY", reference.getStockId(), assignment.sourceSpaceId(),
outboundDiagnostic(snapshot, requestId, reference, assignment.reason())); outboundDiagnostic(snapshot, requestId, reference, assignment.reason()));
} }
if (plan.waiting().isPresent()) { if (plan.waiting().isPresent()) {
var waiting = plan.waiting().orElseThrow(); var waiting = plan.waiting().orElseThrow();
return OutboundShadowObservation.unavailable(snapshot.generation(), snapshot.warehouseId(), return OutboundShadowObservation.unavailable(snapshot,
requestId, "WAITING_" + waiting.code().name(), requestId, "WAITING_" + waiting.code().name(),
outboundDiagnostic(snapshot, requestId, reference, waiting.reason())); outboundDiagnostic(snapshot, requestId, reference, waiting.reason()));
} }
var error = plan.error().orElseThrow(); var error = plan.error().orElseThrow();
return OutboundShadowObservation.unavailable(snapshot.generation(), snapshot.warehouseId(), return OutboundShadowObservation.unavailable(snapshot,
requestId, "ERROR_" + error.code().name(), requestId, "ERROR_" + error.code().name(),
outboundDiagnostic(snapshot, requestId, reference, error.message())); outboundDiagnostic(snapshot, requestId, reference, error.message()));
} }
@@ -253,16 +260,17 @@ public class ShadowPlanningEngine {
} }
} }
private void requirePublishedGeneration(long generation, String warehouseId, String message) { private void validateComparisonSnapshot(long generation, String warehouseId,
if (generation <= 0 || isBlank(warehouseId)) { Instant synchronizedAt, String sourceSignature) {
throw new IllegalStateException(message); if (generation <= 0 || isBlank(warehouseId) || synchronizedAt == null) {
throw new IllegalStateException("published snapshot changed before comparison");
} }
ShadowSnapshotDescriptor descriptor = inputMapper.selectPublishedSnapshot(warehouseId); requireUppercaseHexSignature(sourceSignature, "comparison source signature");
if (descriptor == null }
|| !PUBLISHED.equals(descriptor.getSnapshotStatus())
|| descriptor.getSnapshotGeneration() != generation private void requireComparisonInserted(int inserted) {
|| !WarehouseIdNormalizer.equivalent(descriptor.getWarehouseId(), warehouseId)) { if (inserted != 1) {
throw new IllegalStateException(message); throw new IllegalStateException("published snapshot changed before comparison");
} }
} }

View File

@@ -6,6 +6,8 @@ import com.ruoyi.algorithm.shadow.ShadowStockReference;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import java.time.Instant;
@Mapper @Mapper
public interface ShadowInputMapper { public interface ShadowInputMapper {
ShadowInboundReference selectInboundInput(@Param("warehouseId") String warehouseId, ShadowInboundReference selectInboundInput(@Param("warehouseId") String warehouseId,
@@ -31,6 +33,8 @@ public interface ShadowInputMapper {
@Param("warehouseId") String warehouseId, @Param("warehouseId") String warehouseId,
@Param("decisionType") String decisionType, @Param("decisionType") String decisionType,
@Param("inputVersion") long inputVersion, @Param("inputVersion") long inputVersion,
@Param("snapshotSynchronizedAt") Instant snapshotSynchronizedAt,
@Param("sourceSignature") String sourceSignature,
@Param("resultCode") String resultCode, @Param("resultCode") String resultCode,
@Param("reason") String reason); @Param("reason") String reason);
} }

View File

@@ -96,15 +96,20 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
state.snapshot_status as snapshotStatus, state.snapshot_status as snapshotStatus,
state.synchronized_at as synchronizedAt, state.synchronized_at as synchronizedAt,
convert(varchar(64), state.source_signature, 2) as sourceSignature convert(varchar(64), state.source_signature, 2) as sourceSignature
from alg.shadow_snapshot_state state from alg.shadow_snapshot_state state with (readcommittedlock)
where state.warehouse_id = #{warehouseId} where state.warehouse_id = #{warehouseId}
</select> </select>
<insert id="insertComparison"> <insert id="insertComparison">
insert into alg.decision_log ( insert into alg.decision_log (
decision_id, warehouse_id, decision_type, input_version, result_code, reason) decision_id, warehouse_id, decision_type, input_version, result_code, reason)
values ( select try_convert(uniqueidentifier, #{decisionId}), state.warehouse_id, #{decisionType},
try_convert(uniqueidentifier, #{decisionId}), #{warehouseId}, #{decisionType}, convert(binary(8), #{inputVersion}), #{resultCode}, #{reason}
convert(binary(8), #{inputVersion}), #{resultCode}, #{reason}) from alg.shadow_snapshot_state state with (updlock, holdlock)
where state.warehouse_id = #{warehouseId}
and state.snapshot_status = 'PUBLISHED'
and state.snapshot_generation = #{inputVersion}
and state.synchronized_at = #{snapshotSynchronizedAt}
and convert(varchar(64), state.source_signature, 2) = #{sourceSignature}
</insert> </insert>
</mapper> </mapper>

View File

@@ -141,7 +141,8 @@ class ShadowPlanningEngineTest {
assertThatThrownBy(() -> engine.planInbound(SNAPSHOT, "TASK-1", "TRAY-1")) assertThatThrownBy(() -> engine.planInbound(SNAPSHOT, "TASK-1", "TRAY-1"))
.isInstanceOf(IllegalStateException.class) .isInstanceOf(IllegalStateException.class)
.hasMessageContaining("published snapshot changed during planning"); .hasMessageContaining("published snapshot changed during planning");
verify(mapper, never()).insertComparison(any(), any(), any(), any(Long.class), any(), any()); verify(mapper, never()).insertComparison(any(), any(), any(), any(Long.class),
any(Instant.class), any(), any(), any());
} }
@Test @Test
@@ -160,30 +161,66 @@ class ShadowPlanningEngineTest {
@Test @Test
void comparisonPersistsLeasedGenerationAsInputVersion() { void comparisonPersistsLeasedGenerationAsInputVersion() {
ShadowInputMapper mapper = mock(ShadowInputMapper.class); ShadowInputMapper mapper = mock(ShadowInputMapper.class);
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(SNAPSHOT)); when(mapper.insertComparison(any(), any(), any(), any(Long.class),
any(Instant.class), any(), any(), any())).thenReturn(1);
ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper); ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper);
InboundShadowObservation observation = new InboundShadowObservation( InboundShadowObservation observation = new InboundShadowObservation(
true, 42L, "01", "JOB-1", "READY", "SPACE-9", "shadow diagnostic"); true, 42L, "01", SNAPSHOT.synchronizedAt(), SNAPSHOT.sourceSignature(),
"JOB-1", "READY", "SPACE-9", "shadow diagnostic");
engine.compareInbound(observation, "READY", "SPACE-9"); engine.compareInbound(observation, "READY", "SPACE-9");
verify(mapper).insertComparison(any(), eq("01"), eq("INBOUND_COMPARE"), eq(42L), verify(mapper).insertComparison(any(), eq("01"), eq("INBOUND_COMPARE"), eq(42L),
eq("MATCH"), argThat(reason -> reason.contains("request=JOB-1"))); eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), eq("MATCH"),
argThat(reason -> reason.contains("request=JOB-1")));
} }
@Test @Test
void comparisonDoesNotPersistAfterPublishedGenerationChanges() { void comparisonFailsWhenAtomicDescriptorGuardRejectsTheInsert() {
ShadowInputMapper mapper = mock(ShadowInputMapper.class); ShadowInputMapper mapper = mock(ShadowInputMapper.class);
when(mapper.selectPublishedSnapshot("01"))
.thenReturn(descriptor(snapshot(43L, "01")));
ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper); ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper);
InboundShadowObservation observation = new InboundShadowObservation( InboundShadowObservation observation = new InboundShadowObservation(
true, 42L, "01", "JOB-1", "READY", "SPACE-9", "shadow diagnostic"); true, 42L, "01", SNAPSHOT.synchronizedAt(), SNAPSHOT.sourceSignature(),
"JOB-1", "READY", "SPACE-9", "shadow diagnostic");
assertThatThrownBy(() -> engine.compareInbound(observation, "READY", "SPACE-9")) assertThatThrownBy(() -> engine.compareInbound(observation, "READY", "SPACE-9"))
.isInstanceOf(IllegalStateException.class) .isInstanceOf(IllegalStateException.class)
.hasMessageContaining("published snapshot changed before comparison"); .hasMessageContaining("published snapshot changed before comparison");
verify(mapper, never()).insertComparison(any(), any(), any(), any(Long.class), any(), any()); verify(mapper).insertComparison(any(), eq("01"), eq("INBOUND_COMPARE"), eq(42L),
eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), any(), any());
}
@Test
void outboundComparisonCarriesTheCompleteSnapshotDescriptor() {
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
when(mapper.insertComparison(any(), any(), any(), any(Long.class),
any(Instant.class), any(), any(), any())).thenReturn(1);
ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper);
OutboundShadowObservation observation = new OutboundShadowObservation(
true, 42L, "01", SNAPSHOT.synchronizedAt(), SNAPSHOT.sourceSignature(),
"DETAIL-1", "READY", "STOCK-1", "SPACE-9", "shadow diagnostic");
engine.compareOutbound(observation, "10");
verify(mapper).insertComparison(any(), eq("01"), eq("OUTBOUND_COMPARE"), eq(42L),
eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), eq("LEGACY_10"), any());
}
@Test
void comparisonReasonIsBoundedToDecisionLogCapacity() {
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
when(mapper.insertComparison(any(), any(), any(), any(Long.class),
any(Instant.class), any(), any(), any())).thenReturn(1);
ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper);
InboundShadowObservation observation = new InboundShadowObservation(
true, 42L, "01", SNAPSHOT.synchronizedAt(), SNAPSHOT.sourceSignature(),
"JOB-1", "READY", "SPACE-9", "X".repeat(2500));
engine.compareInbound(observation, "READY", "SPACE-9");
verify(mapper).insertComparison(any(), any(), any(), any(Long.class),
any(Instant.class), any(), any(),
argThat(reason -> reason.length() <= 2000));
} }
@Test @Test
@@ -247,7 +284,12 @@ class ShadowPlanningEngineTest {
assertThat(statements.get("selectStockReference")) assertThat(statements.get("selectStockReference"))
.contains("alg.inventory_item_snapshot", "stock_id = #{stockid}"); .contains("alg.inventory_item_snapshot", "stock_id = #{stockid}");
assertThat(statements.get("insertComparison")) assertThat(statements.get("insertComparison"))
.contains("convert(binary(8), #{inputversion})"); .contains("convert(binary(8), #{inputversion})",
"from alg.shadow_snapshot_state state with (updlock, holdlock)",
"state.snapshot_status = 'published'",
"state.snapshot_generation = #{inputversion}",
"state.synchronized_at = #{snapshotsynchronizedat}",
"convert(varchar(64), state.source_signature, 2) = #{sourcesignature}");
assertThat(statements.get("selectPublishedSnapshot")) assertThat(statements.get("selectPublishedSnapshot"))
.contains("alg.shadow_snapshot_state", "convert(varchar(64),", .contains("alg.shadow_snapshot_state", "convert(varchar(64),",
"source_signature", ", 2)"); "source_signature", ", 2)");