From d77fac1d5695aa1265f55fadf7b883afb0a1e2e0 Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Thu, 3 Sep 2026 15:07:37 +0800 Subject: [PATCH] fix: enforce stable shadow snapshot boundaries --- .../repository/JdbcWarehouseRepository.java | 12 +++- .../resources/db/sqlserver/install-shadow.sql | 47 ++++++++----- .../db/sqlserver/upgrade-shadow-v2-to-v3.sql | 48 +++++++++----- .../JdbcWarehouseRepositoryTest.java | 18 +++++ .../ShadowSqlServerMigrationScriptTest.java | 19 ++++++ .../src/test/resources/db/h2/schema.sql | 2 + .../shadow/InboundShadowObservation.java | 25 +++++-- .../shadow/OutboundShadowObservation.java | 28 ++++++-- .../shadow/ShadowPlanningEngine.java | 66 +++++++++++-------- .../shadow/mapper/ShadowInputMapper.java | 4 ++ .../mapper/algorithm/ShadowInputMapper.xml | 13 ++-- .../shadow/ShadowPlanningEngineTest.java | 62 ++++++++++++++--- 12 files changed, 256 insertions(+), 88 deletions(-) diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/JdbcWarehouseRepository.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/JdbcWarehouseRepository.java index 437a558..c56c89d 100644 --- a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/JdbcWarehouseRepository.java +++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/repository/JdbcWarehouseRepository.java @@ -379,7 +379,9 @@ public final class JdbcWarehouseRepository implements WarehouseRepository { + "ORDER BY lane_code, depth, space_id", parameters, rawSpaceMapper()); List 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 " - + "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) " + "ORDER BY i.space_id, i.stock_id", parameters, inventoryMapper()); Map> inventoryBySpace = inventory.stream() @@ -474,7 +476,9 @@ public final class JdbcWarehouseRepository implements WarehouseRepository { .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, " + "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 + " ORDER BY i.inbound_at, i.inbound_time_raw, i.stock_id"; List 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"; Integer count = named.queryForObject("SELECT COUNT(DISTINCT " + usableTray + ") FROM " + 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 s.role = 'STORAGE' AND s.depth IS NOT NULL AND s.pending_outbound = " + dialect.falseLiteral() + " AND s.reserved = " + dialect.falseLiteral() diff --git a/fortucky-algorithm/src/main/resources/db/sqlserver/install-shadow.sql b/fortucky-algorithm/src/main/resources/db/sqlserver/install-shadow.sql index 11843d6..afce064 100644 --- a/fortucky-algorithm/src/main/resources/db/sqlserver/install-shadow.sql +++ b/fortucky-algorithm/src/main/resources/db/sqlserver/install-shadow.sql @@ -966,9 +966,9 @@ BEGIN + (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 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); + WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)); SELECT @source_payload = ISNULL(CONVERT(nvarchar(max), ( 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.product_id AS [@product_id], 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) 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 - WHERE job.warehouse_id = @warehouse_id + WHERE job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '' ORDER BY inbound.stockin_detail_id FOR XML PATH('inbound'), ROOT('inbound_requests'), TYPE )), ''); 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], + warehouse.project_no AS [@project_no], 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] 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 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)) = ''); ;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, inbound.spare4 AS project_no, inbound.product_id, inbound.lot_no, 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 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 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 ( warehouse_id, task_no, tray_no, request_id, raw_warehouse_id, 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, @snapshot_generation, @synchronized_at FROM inbound_source @@ -1120,11 +1125,14 @@ BEGIN INSERT INTO alg.shadow_outbound_request_snapshot ( warehouse_id, stockout_detail_id, raw_warehouse_id, project_no, product_id, requested_quantity, snapshot_generation, synchronized_at) - SELECT outbound.warehouse_id, outbound.stockout_detail_id, outbound.warehouse_id, - NULL, outbound.product_id, CONVERT(decimal(38,6), outbound.quantity), + SELECT @warehouse_id, outbound.stockout_detail_id, outbound.warehouse_id, + NULLIF(LTRIM(RTRIM(warehouse.project_no)), ''), outbound.product_id, + CONVERT(decimal(38,6), outbound.quantity), @snapshot_generation, @synchronized_at 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, @snapshot_generation = @snapshot_generation; @@ -1145,9 +1153,9 @@ BEGIN + (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 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); + WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)); SELECT @source_payload = ISNULL(CONVERT(nvarchar(max), ( 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.product_id AS [@product_id], 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) 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 - WHERE job.warehouse_id = @warehouse_id + WHERE job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '' ORDER BY inbound.stockin_detail_id FOR XML PATH('inbound'), ROOT('inbound_requests'), TYPE )), ''); 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], + warehouse.project_no AS [@project_no], 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] 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 FOR XML PATH('outbound'), ROOT('outbound_requests'), TYPE )), ''); diff --git a/fortucky-algorithm/src/main/resources/db/sqlserver/upgrade-shadow-v2-to-v3.sql b/fortucky-algorithm/src/main/resources/db/sqlserver/upgrade-shadow-v2-to-v3.sql index b4db8fe..8dc1e12 100644 --- a/fortucky-algorithm/src/main/resources/db/sqlserver/upgrade-shadow-v2-to-v3.sql +++ b/fortucky-algorithm/src/main/resources/db/sqlserver/upgrade-shadow-v2-to-v3.sql @@ -304,9 +304,9 @@ BEGIN 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 - 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); + WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)); SELECT @source_payload = ISNULL(CONVERT(nvarchar(max), ( 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.product_id AS [@product_id], 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) 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 - WHERE job.warehouse_id = @warehouse_id + WHERE job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '' ORDER BY inbound.stockin_detail_id FOR XML PATH('inbound'), ROOT('inbound_requests'), TYPE )), ''); 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], + warehouse.project_no AS [@project_no], 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] 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 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)) = ''); ;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, inbound.spare4 AS project_no, inbound.product_id, inbound.lot_no, 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 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 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 ( warehouse_id, task_no, tray_no, request_id, raw_warehouse_id, 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, @snapshot_generation, @synchronized_at FROM inbound_source @@ -454,11 +459,14 @@ BEGIN INSERT INTO alg.shadow_outbound_request_snapshot ( warehouse_id, stockout_detail_id, raw_warehouse_id, project_no, product_id, requested_quantity, snapshot_generation, synchronized_at) - SELECT outbound.warehouse_id, outbound.stockout_detail_id, outbound.warehouse_id, - NULL, outbound.product_id, CONVERT(decimal(38,6), outbound.quantity), + SELECT @warehouse_id, outbound.stockout_detail_id, outbound.warehouse_id, + NULLIF(LTRIM(RTRIM(warehouse.project_no)), ''), outbound.product_id, + CONVERT(decimal(38,6), outbound.quantity), @snapshot_generation, @synchronized_at 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, @snapshot_generation = @snapshot_generation; @@ -479,8 +487,9 @@ BEGIN + (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 job WITH (NOLOCK) ON job.job_id = detail.job_id - WHERE job.warehouse_id = @warehouse_id) - + (SELECT COUNT_BIG(*) FROM dbo.out_stockout_detail outbound WITH (NOLOCK) WHERE outbound.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 ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)); SELECT @source_payload = ISNULL(CONVERT(nvarchar(max), ( 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], 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], - 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) 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 - WHERE job.warehouse_id = @warehouse_id + WHERE job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '' ORDER BY inbound.stockin_detail_id FOR XML PATH('inbound'), ROOT('inbound_requests'), TYPE )), ''); 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], + warehouse.project_no AS [@project_no], 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] 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 FOR XML PATH('outbound'), ROOT('outbound_requests'), TYPE )), ''); diff --git a/fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/JdbcWarehouseRepositoryTest.java b/fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/JdbcWarehouseRepositoryTest.java index 91a8572..49620eb 100644 --- a/fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/JdbcWarehouseRepositoryTest.java +++ b/fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/JdbcWarehouseRepositoryTest.java @@ -86,6 +86,24 @@ class JdbcWarehouseRepositoryTest { 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 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 void statusFourPhysicalOuterPalletIsPreservedAndMakesDeepOutboundWait() throws Exception { execute("INSERT INTO BAS_CARGO_SPACE VALUES ('ALLOC-TARGET','W1','RA','A','Target',7,21,2,'2',''," diff --git a/fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/ShadowSqlServerMigrationScriptTest.java b/fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/ShadowSqlServerMigrationScriptTest.java index 5121dce..6a26d0f 100644 --- a/fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/ShadowSqlServerMigrationScriptTest.java +++ b/fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/ShadowSqlServerMigrationScriptTest.java @@ -208,6 +208,25 @@ class ShadowSqlServerMigrationScriptTest { "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 void plannerViewsContainOnlyGenerationQualifiedAlgSnapshots() throws IOException { String install = normalize(resource("db/sqlserver/install-shadow.sql")); diff --git a/fortucky-algorithm/src/test/resources/db/h2/schema.sql b/fortucky-algorithm/src/test/resources/db/h2/schema.sql index b6d5c99..2f30ba3 100644 --- a/fortucky-algorithm/src/test/resources/db/h2/schema.sql +++ b/fortucky-algorithm/src/test/resources/db/h2/schema.sql @@ -178,6 +178,7 @@ $$; CREATE VIEW ALG_V_INVENTORY_ITEM AS SELECT + WAREHOUSE_ID, CAST(0 AS BIGINT) AS SNAPSHOT_GENERATION, STOCK_ID, SPACE_ID, TRAY_CODE, PROJECT_NO, PRODUCT_NO, LOT_NO, INBOUND_TIME_RAW, INBOUND_AT, QUANTITY, OUT_QUANTITY FROM ALG_INVENTORY_ITEM_SNAPSHOT; @@ -281,6 +282,7 @@ LANE_GAPS AS ( SELECT C.CARGO_SPACE_ID AS SPACE_ID, C.WAREHOUSE_ID, + CAST(0 AS BIGINT) AS SNAPSHOT_GENERATION, C.GROUP_CODING_NO AS LANE_CODE, C.CARGO_AREA_ID AS AREA_ID, CASE diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/InboundShadowObservation.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/InboundShadowObservation.java index 0ff8f18..8ab88a8 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/InboundShadowObservation.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/InboundShadowObservation.java @@ -1,26 +1,43 @@ package com.ruoyi.algorithm.shadow; +import java.time.Instant; + public record InboundShadowObservation( boolean observed, long snapshotGeneration, String warehouseId, + Instant snapshotSynchronizedAt, + String sourceSignature, String requestId, String resultCode, String proposedSpaceId, 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, 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() { - 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, String requestId, String resultCode, String reason) { - return new InboundShadowObservation(true, snapshotGeneration, warehouseId, requestId, - resultCode, null, reason); + return new InboundShadowObservation(true, snapshotGeneration, warehouseId, null, null, + 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); } } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/OutboundShadowObservation.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/OutboundShadowObservation.java index 4aef62d..28ed39f 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/OutboundShadowObservation.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/OutboundShadowObservation.java @@ -1,27 +1,47 @@ package com.ruoyi.algorithm.shadow; +import java.time.Instant; + public record OutboundShadowObservation( boolean observed, long snapshotGeneration, String warehouseId, + Instant snapshotSynchronizedAt, + String sourceSignature, String requestId, String resultCode, String proposedStockId, String proposedSpaceId, 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, 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() { - 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, String requestId, String resultCode, String reason) { - return new OutboundShadowObservation(true, snapshotGeneration, warehouseId, requestId, - resultCode, null, null, reason); + return new OutboundShadowObservation(true, snapshotGeneration, warehouseId, null, null, + 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); } } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java index aa7c91e..7643006 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java @@ -21,6 +21,7 @@ import java.util.function.Supplier; @Component public class ShadowPlanningEngine { private static final int INSTANCE_STALE_SECONDS = 120; + private static final int DECISION_REASON_MAX_LENGTH = 2000; private static final String PUBLISHED = "PUBLISHED"; private final SchedulingFacade schedulingFacade; @@ -81,8 +82,8 @@ public class ShadowPlanningEngine { ShadowStockReference reference = inputMapper.selectProjectOutboundReference( warehouseId, snapshot.generation(), stockoutDetailId); if (reference == null || isBlank(reference.getProjectNo())) { - return OutboundShadowObservation.unavailable(snapshot.generation(), warehouseId, - stockoutDetailId, "SKIPPED_MISSING_PROJECT", + return OutboundShadowObservation.unavailable(snapshot, stockoutDetailId, + "SKIPPED_MISSING_PROJECT", outboundDiagnostic(snapshot, stockoutDetailId, reference, "No project number was available for shadow planning")); } @@ -100,8 +101,8 @@ public class ShadowPlanningEngine { ShadowStockReference reference = inputMapper.selectStockReference( warehouseId, snapshot.generation(), stockId); if (reference == null || isBlank(reference.getTrayNo())) { - return OutboundShadowObservation.unavailable(snapshot.generation(), warehouseId, - stockoutDetailId, "SKIPPED_MISSING_STOCK", + return OutboundShadowObservation.unavailable(snapshot, stockoutDetailId, + "SKIPPED_MISSING_STOCK", outboundDiagnostic(snapshot, stockoutDetailId, reference, "Selected stock was unavailable to shadow planning")); } @@ -116,37 +117,41 @@ public class ShadowPlanningEngine { String legacySpaceId) { Objects.requireNonNull(observation, "observation"); transactions.executeWithoutResult(status -> { - requirePublishedGeneration(observation.snapshotGeneration(), observation.warehouseId(), - "published snapshot changed before comparison"); - inputMapper.insertComparison( + validateComparisonSnapshot(observation.snapshotGeneration(), observation.warehouseId(), + observation.snapshotSynchronizedAt(), observation.sourceSignature()); + int inserted = inputMapper.insertComparison( UUID.randomUUID().toString(), observation.warehouseId(), "INBOUND_COMPARE", - observation.snapshotGeneration(), + observation.snapshotGeneration(), observation.snapshotSynchronizedAt(), + observation.sourceSignature(), comparisonCode(observation.proposedSpaceId(), legacySpaceId), - "snapshotGeneration=" + observation.snapshotGeneration() + abbreviate("snapshotGeneration=" + observation.snapshotGeneration() + "; request=" + observation.requestId() + "; shadow=" + observation.resultCode() + "; proposedSpace=" + nullSafe(observation.proposedSpaceId()) + "; shadowReason=" + nullSafe(observation.reason()) + "; legacy=" + nullSafe(legacyResultCode) - + "; legacySpace=" + nullSafe(legacySpaceId)); + + "; legacySpace=" + nullSafe(legacySpaceId), DECISION_REASON_MAX_LENGTH)); + requireComparisonInserted(inserted); }); } public void compareOutbound(OutboundShadowObservation observation, String legacyResultCode) { Objects.requireNonNull(observation, "observation"); transactions.executeWithoutResult(status -> { - requirePublishedGeneration(observation.snapshotGeneration(), observation.warehouseId(), - "published snapshot changed before comparison"); - inputMapper.insertComparison( + validateComparisonSnapshot(observation.snapshotGeneration(), observation.warehouseId(), + observation.snapshotSynchronizedAt(), observation.sourceSignature()); + int inserted = inputMapper.insertComparison( UUID.randomUUID().toString(), observation.warehouseId(), "OUTBOUND_COMPARE", - observation.snapshotGeneration(), "LEGACY_" + nullSafe(legacyResultCode), - "snapshotGeneration=" + observation.snapshotGeneration() + observation.snapshotGeneration(), observation.snapshotSynchronizedAt(), + observation.sourceSignature(), "LEGACY_" + nullSafe(legacyResultCode), + abbreviate("snapshotGeneration=" + observation.snapshotGeneration() + "; request=" + observation.requestId() + "; shadow=" + observation.resultCode() + "; proposedStock=" + nullSafe(observation.proposedStockId()) + "; proposedSpace=" + nullSafe(observation.proposedSpaceId()) + "; 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( warehouseId, snapshot.generation(), taskNo, trayNo); if (reference == null || isBlank(reference.getRequestId()) || isBlank(reference.getTrayNo())) { - return InboundShadowObservation.unavailable(snapshot.generation(), warehouseId, taskNo, + return InboundShadowObservation.unavailable(snapshot, taskNo, "SKIPPED_MISSING_INPUT", inboundDiagnostic(snapshot, taskNo, trayNo, reference, "Inbound task details were unavailable to shadow planning")); @@ -169,11 +174,12 @@ public class ShadowPlanningEngine { if (plan.assignment().isPresent()) { var assignment = plan.assignment().orElseThrow(); return new InboundShadowObservation(true, snapshot.generation(), warehouseId, + snapshot.synchronizedAt(), snapshot.sourceSignature(), reference.getRequestId(), "READY", assignment.spaceId(), inboundDiagnostic(snapshot, taskNo, trayNo, reference, assignment.reason())); } var rejection = plan.rejection().orElseThrow(); - return InboundShadowObservation.unavailable(snapshot.generation(), warehouseId, + return InboundShadowObservation.unavailable(snapshot, reference.getRequestId(), "REJECTED_" + rejection.code().name(), inboundDiagnostic(snapshot, taskNo, trayNo, reference, rejection.reason())); } @@ -186,17 +192,18 @@ public class ShadowPlanningEngine { if (plan.assignment().isPresent()) { var assignment = plan.assignment().orElseThrow(); return new OutboundShadowObservation(true, snapshot.generation(), snapshot.warehouseId(), + snapshot.synchronizedAt(), snapshot.sourceSignature(), requestId, "READY", reference.getStockId(), assignment.sourceSpaceId(), outboundDiagnostic(snapshot, requestId, reference, assignment.reason())); } if (plan.waiting().isPresent()) { var waiting = plan.waiting().orElseThrow(); - return OutboundShadowObservation.unavailable(snapshot.generation(), snapshot.warehouseId(), + return OutboundShadowObservation.unavailable(snapshot, requestId, "WAITING_" + waiting.code().name(), outboundDiagnostic(snapshot, requestId, reference, waiting.reason())); } var error = plan.error().orElseThrow(); - return OutboundShadowObservation.unavailable(snapshot.generation(), snapshot.warehouseId(), + return OutboundShadowObservation.unavailable(snapshot, requestId, "ERROR_" + error.code().name(), outboundDiagnostic(snapshot, requestId, reference, error.message())); } @@ -253,16 +260,17 @@ public class ShadowPlanningEngine { } } - private void requirePublishedGeneration(long generation, String warehouseId, String message) { - if (generation <= 0 || isBlank(warehouseId)) { - throw new IllegalStateException(message); + private void validateComparisonSnapshot(long generation, String warehouseId, + Instant synchronizedAt, String sourceSignature) { + if (generation <= 0 || isBlank(warehouseId) || synchronizedAt == null) { + throw new IllegalStateException("published snapshot changed before comparison"); } - ShadowSnapshotDescriptor descriptor = inputMapper.selectPublishedSnapshot(warehouseId); - if (descriptor == null - || !PUBLISHED.equals(descriptor.getSnapshotStatus()) - || descriptor.getSnapshotGeneration() != generation - || !WarehouseIdNormalizer.equivalent(descriptor.getWarehouseId(), warehouseId)) { - throw new IllegalStateException(message); + requireUppercaseHexSignature(sourceSignature, "comparison source signature"); + } + + private void requireComparisonInserted(int inserted) { + if (inserted != 1) { + throw new IllegalStateException("published snapshot changed before comparison"); } } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java index 0599ab7..f75fb5c 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java @@ -6,6 +6,8 @@ import com.ruoyi.algorithm.shadow.ShadowStockReference; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; +import java.time.Instant; + @Mapper public interface ShadowInputMapper { ShadowInboundReference selectInboundInput(@Param("warehouseId") String warehouseId, @@ -31,6 +33,8 @@ public interface ShadowInputMapper { @Param("warehouseId") String warehouseId, @Param("decisionType") String decisionType, @Param("inputVersion") long inputVersion, + @Param("snapshotSynchronizedAt") Instant snapshotSynchronizedAt, + @Param("sourceSignature") String sourceSignature, @Param("resultCode") String resultCode, @Param("reason") String reason); } diff --git a/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml b/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml index c4463e0..03f7504 100644 --- a/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml +++ b/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml @@ -96,15 +96,20 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" state.snapshot_status as snapshotStatus, state.synchronized_at as synchronizedAt, 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} insert into alg.decision_log ( decision_id, warehouse_id, decision_type, input_version, result_code, reason) - values ( - try_convert(uniqueidentifier, #{decisionId}), #{warehouseId}, #{decisionType}, - convert(binary(8), #{inputVersion}), #{resultCode}, #{reason}) + select try_convert(uniqueidentifier, #{decisionId}), state.warehouse_id, #{decisionType}, + 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} diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java index 65158b3..975c4e5 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java @@ -141,7 +141,8 @@ class ShadowPlanningEngineTest { assertThatThrownBy(() -> engine.planInbound(SNAPSHOT, "TASK-1", "TRAY-1")) .isInstanceOf(IllegalStateException.class) .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 @@ -160,30 +161,66 @@ class ShadowPlanningEngineTest { @Test void comparisonPersistsLeasedGenerationAsInputVersion() { 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); 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"); 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 - void comparisonDoesNotPersistAfterPublishedGenerationChanges() { + void comparisonFailsWhenAtomicDescriptorGuardRejectsTheInsert() { ShadowInputMapper mapper = mock(ShadowInputMapper.class); - when(mapper.selectPublishedSnapshot("01")) - .thenReturn(descriptor(snapshot(43L, "01"))); ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper); 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")) .isInstanceOf(IllegalStateException.class) .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 @@ -247,7 +284,12 @@ class ShadowPlanningEngineTest { assertThat(statements.get("selectStockReference")) .contains("alg.inventory_item_snapshot", "stock_id = #{stockid}"); 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")) .contains("alg.shadow_snapshot_state", "convert(varchar(64),", "source_signature", ", 2)");