perf(selfop): build versioned warehouse daily sales summaries

This commit is contained in:
2026-10-13 09:40:55 +08:00
parent ca4e7086e9
commit 25db631ff7
24 changed files with 320 additions and 31 deletions
+3 -1
View File
@@ -1,5 +1,7 @@
# 单测试环境数据库空库初始化 # 单测试环境数据库空库初始化
`migrations/overseas/baiyue_lastleg/V22__selfop_warehouse_daily_sales.sql` 新增自营仓库每日汇总表及销量版本 `warehouse_daily_ready` 标记。与匹配 Lastleg 一起发布;汇总随 SKU 日事实批次原子构建,完整性验证后方可发布。已有未构建汇总的销量版本须重新构建发布,不回填旧版本、不回退旧查询;空版本同样必须有就绪证据。源基线与海外空库基线同步。
`migrations/overseas/baiyue_lastleg/V21__procurement_fee_confirmation_origin.sql` 增加采购费用核定入口,区分业务节点与财务操作,用于独立新增费用、替代核定及其重试权限。同步源基线和海外统一空库基线;不回填测试存量,不表示已执行。 `migrations/overseas/baiyue_lastleg/V21__procurement_fee_confirmation_origin.sql` 增加采购费用核定入口,区分业务节点与财务操作,用于独立新增费用、替代核定及其重试权限。同步源基线和海外统一空库基线;不回填测试存量,不表示已执行。
`migrations/overseas/baiyue_lastleg/V20__procurement_exception_control.sql` 增加采购事实更正/阻断说明、异常关闭及逐来源最终去向三表。服务端保持原收货和发运记录,关闭不产生库存、退款或退货运输;需与采购详情聚合和节点阻断校验的匹配版本发布。源基线和海外统一基线同步,本文件存在不表示已执行。 `migrations/overseas/baiyue_lastleg/V20__procurement_exception_control.sql` 增加采购事实更正/阻断说明、异常关闭及逐来源最终去向三表。服务端保持原收货和发运记录,关闭不产生库存、退款或退货运输;需与采购详情聚合和节点阻断校验的匹配版本发布。源基线和海外统一基线同步,本文件存在不表示已执行。
@@ -31,7 +33,7 @@
| 区域 | 完整入口 | 服务 schema | 表数 | | 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:| |---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 | | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 194 | | 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 195 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 - 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS ll_selfop_warehouse_daily_sales (
sales_run_id BIGINT NOT NULL COMMENT '完整销量计算版本标识',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码',
business_date DATE NOT NULL COMMENT '已核准业务日',
observed_quantity DECIMAL(38,6) NULL COMMENT '该日PCS非负有效出库量之和,无有效行时为空',
invalid_row_count BIGINT NOT NULL COMMENT '该日数量为空或负数或单位非PCS的日事实行数',
source_row_count BIGINT NOT NULL COMMENT '该日参与仓库汇总的SKU日事实总行数',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
PRIMARY KEY (sales_run_id,source_account,warehouse_code,business_date),
CONSTRAINT chk_selfop_warehouse_daily_sales CHECK (source_row_count > 0 AND invalid_row_count >= 0 AND invalid_row_count <= source_row_count AND (observed_quantity IS NULL OR observed_quantity >= 0))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本按仓按日汇总,随SKU日事实批次原子构建';
SET @selfop_warehouse_daily_ready_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_selfop_sync_run' AND column_name='warehouse_daily_ready'),
'SELECT 1',
'ALTER TABLE ll_selfop_sync_run ADD COLUMN warehouse_daily_ready TINYINT NOT NULL DEFAULT 0 COMMENT ''仓库日汇总已完整构建并校验,含完整空版本''');
PREPARE selfop_warehouse_daily_ready_stmt FROM @selfop_warehouse_daily_ready_ddl;
EXECUTE selfop_warehouse_daily_ready_stmt;
DEALLOCATE PREPARE selfop_warehouse_daily_ready_stmt;
@@ -4058,6 +4058,7 @@ CREATE TABLE ll_selfop_sync_run (
fetch_started_at_utc DATETIME(3) NULL COMMENT '实际采集开始UTC时间', fetch_started_at_utc DATETIME(3) NULL COMMENT '实际采集开始UTC时间',
fetch_finished_at_utc DATETIME(3) NULL COMMENT '实际采集完成UTC时间,未完成为空', fetch_finished_at_utc DATETIME(3) NULL COMMENT '实际采集完成UTC时间,未完成为空',
build_started_at_utc DATETIME(3) NULL COMMENT '实际开始有界候选构建UTC时间,用于构建耗时及首采合并间隔', build_started_at_utc DATETIME(3) NULL COMMENT '实际开始有界候选构建UTC时间,用于构建耗时及首采合并间隔',
warehouse_daily_ready TINYINT NOT NULL DEFAULT 0 COMMENT '仓库日汇总已完整构建并校验,含完整空版本',
finished_at_utc DATETIME(3) NULL COMMENT '本次尝试终止UTC时间', finished_at_utc DATETIME(3) NULL COMMENT '本次尝试终止UTC时间',
published_at_utc DATETIME(3) NULL COMMENT '实际原子切换发布指针UTC时间', published_at_utc DATETIME(3) NULL COMMENT '实际原子切换发布指针UTC时间',
superseded_at_utc DATETIME(3) NULL COMMENT '被新发布版替代的首次UTC时间,同事务填写且重试不重置', superseded_at_utc DATETIME(3) NULL COMMENT '被新发布版替代的首次UTC时间,同事务填写且重试不重置',
@@ -4199,6 +4200,20 @@ CREATE TABLE ll_selfop_sku_daily_sales (
CONSTRAINT chk_selfop_sku_daily_sales CHECK (outbound_quantity >= 0 AND outbound_order_count >= 0) CONSTRAINT chk_selfop_sku_daily_sales CHECK (outbound_quantity >= 0 AND outbound_order_count >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本的稀疏日事实'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本的稀疏日事实';
CREATE TABLE ll_selfop_warehouse_daily_sales (
sales_run_id BIGINT NOT NULL COMMENT '完整销量计算版本标识',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码',
business_date DATE NOT NULL COMMENT '已核准业务日',
observed_quantity DECIMAL(38,6) NULL COMMENT '该日PCS非负有效出库量之和,无有效行时为空',
invalid_row_count BIGINT NOT NULL COMMENT '该日数量为空或负数或单位非PCS的日事实行数',
source_row_count BIGINT NOT NULL COMMENT '该日参与仓库汇总的SKU日事实总行数',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
PRIMARY KEY (sales_run_id,source_account,warehouse_code,business_date),
CONSTRAINT chk_selfop_warehouse_daily_sales CHECK (source_row_count > 0 AND invalid_row_count >= 0 AND invalid_row_count <= source_row_count AND (observed_quantity IS NULL OR observed_quantity >= 0))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本按仓按日汇总,随SKU日事实批次原子构建';
CREATE TABLE ll_selfop_sku_profile ( CREATE TABLE ll_selfop_sku_profile (
id BIGINT NOT NULL COMMENT '应用生成的记录标识', id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
@@ -13,6 +13,7 @@ import java.time.LocalDateTime;
public class SelfopSyncRun { public class SelfopSyncRun {
private String executionPurpose; private String executionPurpose;
private LocalDateTime buildStartedAtUtc; private LocalDateTime buildStartedAtUtc;
private Boolean warehouseDailyReady;
private LocalDateTime supersededAtUtc; private LocalDateTime supersededAtUtc;
private String progressSnapshotJson; private String progressSnapshotJson;
private LocalDateTime heartbeatAtUtc; private LocalDateTime heartbeatAtUtc;
@@ -99,6 +99,30 @@ public interface OutboundAggregateMapper {
""") """)
int insertDaily(@Param("rows") List<SelfopSkuDailySales> rows); int insertDaily(@Param("rows") List<SelfopSkuDailySales> rows);
@Insert("""
<script>
INSERT INTO ll_selfop_warehouse_daily_sales
(sales_run_id,source_account,warehouse_code,business_date,observed_quantity,invalid_row_count,source_row_count,created_at,updated_at)
SELECT sales_run_id,source_account,warehouse_code,business_date,
SUM(CASE WHEN quantity_unit='PCS' AND outbound_quantity &gt;= 0 THEN outbound_quantity END),
SUM(CASE WHEN quantity_unit='PCS' AND outbound_quantity &gt;= 0 THEN 0 ELSE 1 END),COUNT(*),MIN(created_at),MAX(updated_at)
FROM ll_selfop_sku_daily_sales WHERE id IN
<foreach collection='rows' item='r' open='(' separator=',' close=')'>#{r.id}</foreach>
GROUP BY sales_run_id,source_account,warehouse_code,business_date
ON DUPLICATE KEY UPDATE
observed_quantity=COALESCE(observed_quantity+VALUES(observed_quantity),observed_quantity,VALUES(observed_quantity)),
invalid_row_count=invalid_row_count+VALUES(invalid_row_count),
source_row_count=source_row_count+VALUES(source_row_count),updated_at=VALUES(updated_at)
</script>
""")
int insertWarehouseDaily(@Param("rows") List<SelfopSkuDailySales> rows);
@Select("SELECT COALESCE(SUM(source_row_count),0) FROM ll_selfop_warehouse_daily_sales WHERE sales_run_id=#{runId} AND source_account=#{account} AND warehouse_code=#{warehouse}")
long warehouseDailySourceRows(@Param("runId") Long runId,@Param("account") String account,@Param("warehouse") String warehouse);
@Delete("DELETE FROM ll_selfop_warehouse_daily_sales WHERE sales_run_id=#{runId}")
int deleteWarehouseDaily(@Param("runId") Long runId);
@Insert(""" @Insert("""
<script> <script>
INSERT INTO ll_selfop_sku_profile INSERT INTO ll_selfop_sku_profile
@@ -35,7 +35,7 @@ public class OutboundCandidateTransaction {
SelfopSupport.require(!Set.of("PUBLISHED","REUSED","FAILED","DEAD").contains(run.getStatus()),"CANDIDATE_RUN_INVALID"); SelfopSupport.require(!Set.of("PUBLISHED","REUSED","FAILED","DEAD").contains(run.getStatus()),"CANDIDATE_RUN_INVALID");
SelfopSupport.require("APPLIED".equals(held.task().getApplyState()) || "REBUILD".equals(held.task().getTaskType()),"TASK_NOT_APPLIED"); SelfopSupport.require("APPLIED".equals(held.task().getApplyState()) || "REBUILD".equals(held.task().getTaskType()),"TASK_NOT_APPLIED");
facts.assertNoBarrier(scope); validateFacts(scope); facts.assertNoBarrier(scope); validateFacts(scope);
if("READY".equals(run.getStatus())) return new Checkpoint(runId,"DONE",null,null,null,0,0,0); if("READY".equals(run.getStatus())) {SelfopSupport.require(Boolean.TRUE.equals(run.getWarehouseDailyReady()),"WAREHOUSE_DAILY_NOT_READY");return new Checkpoint(runId,"DONE",null,null,null,0,0,0);}
Checkpoint existing=checkpoint(held.task()); Checkpoint existing=checkpoint(held.task());
if(run.getBuildStartedAtUtc()!=null && existing!=null && Objects.equals(existing.runId(),runId)) { if(run.getBuildStartedAtUtc()!=null && existing!=null && Objects.equals(existing.runId(),runId)) {
SelfopSupport.require("BUILDING".equals(run.getStatus()) && Objects.equals(scope.getFactRevision(),run.getCalculatedFactRevision()),"CANDIDATE_FACT_REVISION_CHANGED"); SelfopSupport.require("BUILDING".equals(run.getStatus()) && Objects.equals(scope.getFactRevision(),run.getCalculatedFactRevision()),"CANDIDATE_FACT_REVISION_CHANGED");
@@ -44,6 +44,7 @@ public class OutboundCandidateTransaction {
} }
SelfopSupport.require(run.getBuildStartedAtUtc()==null,"CANDIDATE_CHECKPOINT_MISSING"); SelfopSupport.require(run.getBuildStartedAtUtc()==null,"CANDIDATE_CHECKPOINT_MISSING");
// A new attempt has a new run ID; failed attempts are never overwritten or emptied. // A new attempt has a new run ID; failed attempts are never overwritten or emptied.
aggregates.deleteWarehouseDaily(runId);run.setWarehouseDailyReady(false);
daily.delete(new QueryWrapper<SelfopSkuDailySales>().eq("sales_run_id",runId)); daily.delete(new QueryWrapper<SelfopSkuDailySales>().eq("sales_run_id",runId));
profiles.delete(new QueryWrapper<SelfopSkuProfile>().eq("profile_run_id",runId)); profiles.delete(new QueryWrapper<SelfopSkuProfile>().eq("profile_run_id",runId));
OutboundCandidateBuilder.Coverage coverage=coverage(scope); OutboundCandidateBuilder.Coverage coverage=coverage(scope);
@@ -66,7 +67,7 @@ public class OutboundCandidateTransaction {
if(rows.isEmpty()) next=new Checkpoint(runId,"PROFILE",null,null,null,cursor.dailyRows(),0,cursor.committedBatches()); if(rows.isEmpty()) next=new Checkpoint(runId,"PROFILE",null,null,null,cursor.dailyRows(),0,cursor.committedBatches());
else { else {
for(var row:rows){row.setId(IdWorker.getId());row.setSalesRunId(runId);row.setSourceAccount(held.scope().getSourceAccount());row.setWarehouseCode(held.scope().getWarehouseCode());row.setCreatedAt(SelfopSupport.utcNow());row.setUpdatedAt(row.getCreatedAt());} for(var row:rows){row.setId(IdWorker.getId());row.setSalesRunId(runId);row.setSourceAccount(held.scope().getSourceAccount());row.setWarehouseCode(held.scope().getWarehouseCode());row.setCreatedAt(SelfopSupport.utcNow());row.setUpdatedAt(row.getCreatedAt());}
aggregates.insertDaily(rows);var last=rows.get(rows.size()-1); aggregates.insertDaily(rows);aggregates.insertWarehouseDaily(rows);var last=rows.get(rows.size()-1);
SelfopSupport.require(!Objects.equals(cursor.customer(),last.getCustomerCode()) || !Objects.equals(cursor.sku(),last.getSkuCode()) || !Objects.equals(cursor.date(),last.getBusinessDate()),"CANDIDATE_CURSOR_STALLED"); SelfopSupport.require(!Objects.equals(cursor.customer(),last.getCustomerCode()) || !Objects.equals(cursor.sku(),last.getSkuCode()) || !Objects.equals(cursor.date(),last.getBusinessDate()),"CANDIDATE_CURSOR_STALLED");
next=new Checkpoint(runId,"DAILY",last.getCustomerCode(),last.getSkuCode(),last.getBusinessDate(),cursor.dailyRows()+rows.size(),0,cursor.committedBatches()+1); next=new Checkpoint(runId,"DAILY",last.getCustomerCode(),last.getSkuCode(),last.getBusinessDate(),cursor.dailyRows()+rows.size(),0,cursor.committedBatches()+1);
} }
@@ -90,7 +91,8 @@ public class OutboundCandidateTransaction {
SelfopSupport.require(cursor!=null && Objects.equals(cursor.runId(),runId) && "FINISH".equals(cursor.phase()),"CANDIDATE_NOT_COMPLETE"); SelfopSupport.require(cursor!=null && Objects.equals(cursor.runId(),runId) && "FINISH".equals(cursor.phase()),"CANDIDATE_NOT_COMPLETE");
SelfopSupport.require(daily.selectCount(new QueryWrapper<SelfopSkuDailySales>().eq("sales_run_id",runId))==cursor.dailyRows() SelfopSupport.require(daily.selectCount(new QueryWrapper<SelfopSkuDailySales>().eq("sales_run_id",runId))==cursor.dailyRows()
&& profiles.selectCount(new QueryWrapper<SelfopSkuProfile>().eq("profile_run_id",runId))==cursor.profileRows(),"CANDIDATE_ROWS_INCOMPLETE"); && profiles.selectCount(new QueryWrapper<SelfopSkuProfile>().eq("profile_run_id",runId))==cursor.profileRows(),"CANDIDATE_ROWS_INCOMPLETE");
facts.assertNoBarrier(held.scope());held.run().setStatus("READY");save(held,new Checkpoint(runId,"DONE",null,null,null,cursor.dailyRows(),cursor.profileRows(),cursor.committedBatches())); SelfopSupport.require(aggregates.warehouseDailySourceRows(runId,held.scope().getSourceAccount(),held.scope().getWarehouseCode())==cursor.dailyRows(),"WAREHOUSE_DAILY_ROWS_INCOMPLETE");
facts.assertNoBarrier(held.scope());held.run().setWarehouseDailyReady(true);held.run().setStatus("READY");save(held,new Checkpoint(runId,"DONE",null,null,null,cursor.dailyRows(),cursor.profileRows(),cursor.committedBatches()));
} }
@Transactional(isolation=Isolation.REPEATABLE_READ,rollbackFor=Exception.class) @Transactional(isolation=Isolation.REPEATABLE_READ,rollbackFor=Exception.class)
@@ -101,7 +103,7 @@ public class OutboundCandidateTransaction {
facts.assertNoBarrier(scope);validateFacts(scope); facts.assertNoBarrier(scope);validateFacts(scope);
if(scope.getPublishedRunId()==null)return null; if(scope.getPublishedRunId()==null)return null;
SelfopSyncRun published=runs.selectById(scope.getPublishedRunId()); SelfopSyncRun published=runs.selectById(scope.getPublishedRunId());
if(published==null || !"PUBLISHED".equals(published.getStatus()) || !"BUSINESS".equals(published.getExecutionPurpose()) if(published==null || !Boolean.TRUE.equals(published.getWarehouseDailyReady()) || !"PUBLISHED".equals(published.getStatus()) || !"BUSINESS".equals(published.getExecutionPurpose())
|| published.getBuildStartedAtUtc()==null || !Objects.equals(scopeId,published.getScopeId()) || !Objects.equals(scope.getScopeHash(),published.getScopeHash()) || published.getBuildStartedAtUtc()==null || !Objects.equals(scopeId,published.getScopeId()) || !Objects.equals(scope.getScopeHash(),published.getScopeHash())
|| !Objects.equals(scope.getFactRevision(),published.getCalculatedFactRevision()) || !Objects.equals(scope.getFactRevision(),scope.getPublishedFactRevision()) || !Objects.equals(scope.getFactRevision(),published.getCalculatedFactRevision()) || !Objects.equals(scope.getFactRevision(),scope.getPublishedFactRevision())
|| !Objects.equals(SelfopSupport.read(held.run().getScopeSnapshotJson(),Map.class),SelfopSupport.read(published.getScopeSnapshotJson(),Map.class)))return null; || !Objects.equals(SelfopSupport.read(held.run().getScopeSnapshotJson(),Map.class),SelfopSupport.read(published.getScopeSnapshotJson(),Map.class)))return null;
@@ -8,7 +8,7 @@ import org.apache.ibatis.annotations.*;
public interface AnalyticsReadMapper { public interface AnalyticsReadMapper {
String RUN_COLUMNS="id,task_id,scope_id,source_account,warehouse_code,run_role,scope_hash,scope_snapshot_json,status," String RUN_COLUMNS="id,task_id,scope_id,source_account,warehouse_code,run_role,scope_hash,scope_snapshot_json,status,"
+"coverage_status,covered_from_utc,covered_until_utc,complete_day_from,complete_day_until," +"coverage_status,covered_from_utc,covered_until_utc,complete_day_from,complete_day_until,"
+"fetch_started_at_utc,fetch_finished_at_utc,published_at_utc,calculated_fact_revision,error_code,error_message,superseded_at_utc,execution_purpose"; +"fetch_started_at_utc,fetch_finished_at_utc,published_at_utc,calculated_fact_revision,error_code,error_message,superseded_at_utc,execution_purpose,warehouse_daily_ready";
@Select("SELECT id,source_account,warehouse_code,data_family,scope_hash,business_timezone,source_timezone," @Select("SELECT id,source_account,warehouse_code,data_family,scope_hash,business_timezone,source_timezone,"
+"stale_after_seconds,published_run_id,fact_revision,published_fact_revision,rebuild_required," +"stale_after_seconds,published_run_id,fact_revision,published_fact_revision,rebuild_required,"
+"known_order_round_finished_at_utc,history_round_finished_at_utc,review_target_days " +"known_order_round_finished_at_utc,history_round_finished_at_utc,review_target_days "
@@ -19,7 +19,7 @@ public final class AnalyticsReadRows {
LocalDate completeDayFrom, LocalDate completeDayUntil, LocalDate completeDayFrom, LocalDate completeDayUntil,
LocalDateTime fetchStartedAtUtc, LocalDateTime fetchFinishedAtUtc, LocalDateTime fetchStartedAtUtc, LocalDateTime fetchFinishedAtUtc,
LocalDateTime publishedAtUtc, Long calculatedFactRevision, LocalDateTime publishedAtUtc, Long calculatedFactRevision,
String errorCode, String errorMessage, LocalDateTime supersededAtUtc, String executionPurpose) {} String errorCode, String errorMessage, LocalDateTime supersededAtUtc, String executionPurpose, Boolean warehouseDailyReady) {}
public record Profile(String customerCode, String skuCode, String productName, String quantityUnit, public record Profile(String customerCode, String skuCode, String productName, String quantityUnit,
LocalDateTime firstSeenAtUtc, LocalDateTime earliestEvidenceAtUtc, LocalDateTime firstSeenAtUtc, LocalDateTime earliestEvidenceAtUtc,
String evidenceType, Long evidenceRunId, Long lastSeenRunId) {} String evidenceType, Long evidenceRunId, Long lastSeenRunId) {}
@@ -18,6 +18,8 @@ public class DashboardQueryService {
public DashboardResponses.Dashboard dashboard(AnalyticsQuery input){ public DashboardResponses.Dashboard dashboard(AnalyticsQuery input){
AnalyticsQuery q=DashboardRequest.normalize(input); AnalyticsQuery q=DashboardRequest.normalize(input);
var snapshot=contextService.context(q,true,false,false);var c=snapshot.context(); var snapshot=contextService.context(q,true,false,false);var c=snapshot.context();
if(snapshot.sales()!=null&&!Boolean.TRUE.equals(snapshot.sales().warehouseDailyReady()))
throw new com.baiyue.common.core.exception.BizException("该销量版本尚未生成仓库每日汇总,请重新发布统计后再查询");
var base=contextService.request(snapshot,"DASHBOARD");LocalDate fixedFrom=c.anchorDate().minusDays(30),fixedUntil=c.anchorDate(); var base=contextService.request(snapshot,"DASHBOARD");LocalDate fixedFrom=c.anchorDate().minusDays(30),fixedUntil=c.anchorDate();
var p=new AnalyticsSqlRequest(base.account(),base.warehouse(),null,null,base.salesRun(),base.inventoryRun(),fixedFrom,fixedUntil,base.anchor(),fixedFrom, var p=new AnalyticsSqlRequest(base.account(),base.warehouse(),null,null,base.salesRun(),base.inventoryRun(),fixedFrom,fixedUntil,base.anchor(),fixedFrom,
base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.complete30(),base.complete30(),base.stockFresh(),0,10,"DASHBOARD"); base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.complete30(),base.complete30(),base.stockFresh(),0,10,"DASHBOARD");
@@ -12,12 +12,11 @@ public interface DashboardReadMapper {
record Ranked(String rankCode,String customerCode,String skuCode,String productName,BigDecimal mainValue, record Ranked(String rankCode,String customerCode,String skuCode,String productName,BigDecimal mainValue,
BigDecimal ownerOutbound,Long orderCount,BigDecimal physicalQuantity,BigDecimal availableQuantity, BigDecimal ownerOutbound,Long orderCount,BigDecimal physicalQuantity,BigDecimal availableQuantity,
BigDecimal frozenQuantity,BigDecimal defectivePhysicalQuantity,BigDecimal outboundQuantity30d,BigDecimal coverageDays){} BigDecimal frozenQuantity,BigDecimal defectivePhysicalQuantity,BigDecimal outboundQuantity30d,BigDecimal coverageDays){}
// Dense 366-day EXPLAIN: the natural unique-index plan performs non-covering reads; // Published-version warehouse facts retain valid subtotal and invalid-row evidence.
// the date-ordered covering index also matches warehouse daily GROUP BY/ORDER BY. // Coverage and formal-zero proof still come from the pinned run, never row presence.
@Select("SELECT /*+ INDEX(ll_selfop_sku_daily_sales idx_selfop_daily_window_cover) */ business_date,SUM(CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN outbound_quantity END) observed_quantity," @Select("SELECT business_date,observed_quantity,invalid_row_count "
+"COUNT(CASE WHEN quantity_unit IS NULL OR quantity_unit<>'PCS' OR outbound_quantity IS NULL OR outbound_quantity<0 THEN 1 END) invalid_row_count " +"FROM ll_selfop_warehouse_daily_sales WHERE sales_run_id=#{p.salesRun} AND source_account=#{p.account} AND warehouse_code=#{p.warehouse} "
+"FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{p.salesRun} AND source_account=#{p.account} AND warehouse_code=#{p.warehouse} " +"AND ((business_date>=#{chartFrom} AND business_date<#{chartUntil}) OR (business_date>=#{p.from} AND business_date<#{p.until})) ORDER BY business_date")
+"AND ((business_date>=#{chartFrom} AND business_date<#{chartUntil}) OR (business_date>=#{p.from} AND business_date<#{p.until})) GROUP BY business_date ORDER BY business_date")
List<Day> daily(@Param("p") AnalyticsSqlRequest request,@Param("chartFrom") LocalDate chartFrom,@Param("chartUntil") LocalDate chartUntil); List<Day> daily(@Param("p") AnalyticsSqlRequest request,@Param("chartFrom") LocalDate chartFrom,@Param("chartUntil") LocalDate chartUntil);
@SelectProvider(type=DashboardReadSql.class,method="report") @SelectProvider(type=DashboardReadSql.class,method="report")
List<Map<String,Object>> report(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options); List<Map<String,Object>> report(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options);
@@ -20,6 +20,7 @@ public interface SalesRetentionMapper {
AND r.inventory_daily_sample_date IS NULL AND r.inventory_sample_rule_version IS NULL AND r.inventory_daily_sample_date IS NULL AND r.inventory_sample_rule_version IS NULL
AND r.id != #{current} AND (#{previous} IS NULL OR r.id != #{previous}) AND r.id != #{current} AND (#{previous} IS NULL OR r.id != #{previous})
AND (EXISTS (SELECT 1 FROM ll_selfop_sku_daily_sales d WHERE d.sales_run_id=r.id) AND (EXISTS (SELECT 1 FROM ll_selfop_sku_daily_sales d WHERE d.sales_run_id=r.id)
OR EXISTS (SELECT 1 FROM ll_selfop_warehouse_daily_sales w WHERE w.sales_run_id=r.id)
OR EXISTS (SELECT 1 FROM ll_selfop_sku_profile p WHERE p.profile_run_id=r.id)) OR EXISTS (SELECT 1 FROM ll_selfop_sku_profile p WHERE p.profile_run_id=r.id))
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM ll_selfop_sync_run input JOIN ll_selfop_sync_task t ON t.id=input.task_id SELECT 1 FROM ll_selfop_sync_run input JOIN ll_selfop_sync_task t ON t.id=input.task_id
@@ -39,6 +40,8 @@ public interface SalesRetentionMapper {
long activeReferences(@Param("account") String account,@Param("warehouse") String warehouse,@Param("id") Long id); long activeReferences(@Param("account") String account,@Param("warehouse") String warehouse,@Param("id") Long id);
@Delete("DELETE FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{id} LIMIT #{batchSize}") @Delete("DELETE FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{id} LIMIT #{batchSize}")
int deleteDaily(@Param("id") Long id,@Param("batchSize") int batchSize); int deleteDaily(@Param("id") Long id,@Param("batchSize") int batchSize);
@Delete("DELETE FROM ll_selfop_warehouse_daily_sales WHERE sales_run_id=#{id} LIMIT #{batchSize}")
int deleteWarehouseDaily(@Param("id") Long id,@Param("batchSize") int batchSize);
@Delete("DELETE FROM ll_selfop_sku_profile WHERE profile_run_id=#{id} LIMIT #{batchSize}") @Delete("DELETE FROM ll_selfop_sku_profile WHERE profile_run_id=#{id} LIMIT #{batchSize}")
int deleteProfiles(@Param("id") Long id,@Param("batchSize") int batchSize); int deleteProfiles(@Param("id") Long id,@Param("batchSize") int batchSize);
} }
@@ -16,7 +16,7 @@ public class SalesRetentionService {
private final SelfopAnalyticsProperties config; private final SelfopAnalyticsProperties config;
public record Preview(Long scopeId,Long current,Long previous,List<Long> eligibleRunIds,LocalDateTime cutoff) { } public record Preview(Long scopeId,Long current,Long previous,List<Long> eligibleRunIds,LocalDateTime cutoff) { }
public record Deleted(Long runId,int dailyRows,int profileRows) { } public record Deleted(Long runId,int dailyRows,int profileRows,int warehouseDailyRows) { }
@Transactional(isolation=Isolation.READ_COMMITTED,rollbackFor=Exception.class) @Transactional(isolation=Isolation.READ_COMMITTED,rollbackFor=Exception.class)
public Preview preview(Long scopeId,LocalDateTime now) { public Preview preview(Long scopeId,LocalDateTime now) {
@@ -46,7 +46,7 @@ public class SalesRetentionService {
&& !Objects.equals(runId,previous),"SALES_VERSION_PROTECTED"); && !Objects.equals(runId,previous),"SALES_VERSION_PROTECTED");
SelfopSupport.require(mapper.activeReferences(scope.getSourceAccount(),scope.getWarehouseCode(),runId)==0,"SALES_INPUT_REFERENCED"); SelfopSupport.require(mapper.activeReferences(scope.getSourceAccount(),scope.getWarehouseCode(),runId)==0,"SALES_INPUT_REFERENCED");
int size=Math.max(1,Math.min(500,config.getPersistenceBatchSize())); int size=Math.max(1,Math.min(500,config.getPersistenceBatchSize()));
return new Deleted(runId,mapper.deleteDaily(runId,size),mapper.deleteProfiles(runId,size)); return new Deleted(runId,mapper.deleteDaily(runId,size),mapper.deleteProfiles(runId,size),mapper.deleteWarehouseDaily(runId,size));
} }
private void validateScope(SelfopSyncScope scope) { private void validateScope(SelfopSyncScope scope) {
SelfopSupport.require(scope!=null && "OUTBOUND".equals(scope.getDataFamily()),"SALES_RETENTION_SCOPE_INVALID"); SelfopSupport.require(scope!=null && "OUTBOUND".equals(scope.getDataFamily()),"SALES_RETENTION_SCOPE_INVALID");
@@ -25,6 +25,7 @@ public class SelfopSalesPublisher {
SelfopCollectionPolicy.requireExecution(config,"BUSINESS");SelfopCollectionPolicy.requireBusiness(run); SelfopCollectionPolicy.requireExecution(config,"BUSINESS");SelfopCollectionPolicy.requireBusiness(run);
require(scope!=null&&run!=null&&"OUTBOUND".equals(scope.getDataFamily()),"SELFOP_SALES_SCOPE_INVALID"); require(scope!=null&&run!=null&&"OUTBOUND".equals(scope.getDataFamily()),"SELFOP_SALES_SCOPE_INVALID");
SelfopLeaseService.check(new SelfopLeaseService.Lease(scopeId,owner,fence,false),scope.getLeaseOwner(),scope.getFenceToken(),scope.getLeaseUntilUtc(),now); SelfopLeaseService.check(new SelfopLeaseService.Lease(scopeId,owner,fence,false),scope.getLeaseOwner(),scope.getFenceToken(),scope.getLeaseUntilUtc(),now);
require(Boolean.TRUE.equals(run.getWarehouseDailyReady()),"SELFOP_WAREHOUSE_DAILY_NOT_READY");
if(Objects.equals(scope.getPublishedRunId(),runId)&&"PUBLISHED".equals(run.getStatus()))return; if(Objects.equals(scope.getPublishedRunId(),runId)&&"PUBLISHED".equals(run.getStatus()))return;
require(scope.getSourceTimezone()!=null&&!scope.getSourceTimezone().isBlank(),"SOURCE_TIMEZONE_UNCONFIRMED"); require(scope.getSourceTimezone()!=null&&!scope.getSourceTimezone().isBlank(),"SOURCE_TIMEZONE_UNCONFIRMED");
require(config.getQuantityUnit()!=null&&!config.getQuantityUnit().isBlank(),"QUANTITY_UNIT_UNCONFIRMED"); require(config.getQuantityUnit()!=null&&!config.getQuantityUnit().isBlank(),"QUANTITY_UNIT_UNCONFIRMED");
@@ -52,6 +53,7 @@ public class SelfopSalesPublisher {
SelfopCollectionPolicy.requireExecution(config,"BUSINESS");require(config.isSalesPublicationEnabled(),"SELFOP_SALES_PUBLICATION_DISABLED"); SelfopCollectionPolicy.requireExecution(config,"BUSINESS");require(config.isSalesPublicationEnabled(),"SELFOP_SALES_PUBLICATION_DISABLED");
var scope=locks.lockScope(scopeId);var run=locks.lockRun(runId);var task=locks.lockTask(run.getTaskId());var published=runs.selectById(publishedId); var scope=locks.lockScope(scopeId);var run=locks.lockRun(runId);var task=locks.lockTask(run.getTaskId());var published=runs.selectById(publishedId);
SelfopCollectionPolicy.requireBusiness(task);SelfopCollectionPolicy.requireBusiness(run);SelfopCollectionPolicy.requireBusiness(published); SelfopCollectionPolicy.requireBusiness(task);SelfopCollectionPolicy.requireBusiness(run);SelfopCollectionPolicy.requireBusiness(published);
require(published!=null&&Boolean.TRUE.equals(published.getWarehouseDailyReady()),"SELFOP_WAREHOUSE_DAILY_NOT_READY");
SelfopLeaseService.check(new SelfopLeaseService.Lease(scopeId,owner,fence,false),scope.getLeaseOwner(),scope.getFenceToken(),scope.getLeaseUntilUtc(),utcNow()); SelfopLeaseService.check(new SelfopLeaseService.Lease(scopeId,owner,fence,false),scope.getLeaseOwner(),scope.getFenceToken(),scope.getLeaseUntilUtc(),utcNow());
require(Objects.equals(run.getScopeId(),scopeId)&&Objects.equals(run.getFenceToken(),fence)&&Objects.equals(run.getScopeHash(),scope.getScopeHash()),"SELFOP_REUSE_SCOPE_INVALID"); require(Objects.equals(run.getScopeId(),scopeId)&&Objects.equals(run.getFenceToken(),fence)&&Objects.equals(run.getScopeHash(),scope.getScopeHash()),"SELFOP_REUSE_SCOPE_INVALID");
require(Objects.equals(scope.getPublishedRunId(),publishedId)&&"PUBLISHED".equals(published.getStatus())&&Objects.equals(published.getCalculatedFactRevision(),scope.getFactRevision()) require(Objects.equals(scope.getPublishedRunId(),publishedId)&&"PUBLISHED".equals(published.getStatus())&&Objects.equals(published.getCalculatedFactRevision(),scope.getFactRevision())
@@ -3771,6 +3771,7 @@ CREATE TABLE ll_selfop_sync_run (
fetch_started_at_utc DATETIME(3) NULL COMMENT '实际采集开始UTC时间', fetch_started_at_utc DATETIME(3) NULL COMMENT '实际采集开始UTC时间',
fetch_finished_at_utc DATETIME(3) NULL COMMENT '实际采集完成UTC时间,未完成为空', fetch_finished_at_utc DATETIME(3) NULL COMMENT '实际采集完成UTC时间,未完成为空',
build_started_at_utc DATETIME(3) NULL COMMENT '实际开始有界候选构建UTC时间,用于构建耗时及首采合并间隔', build_started_at_utc DATETIME(3) NULL COMMENT '实际开始有界候选构建UTC时间,用于构建耗时及首采合并间隔',
warehouse_daily_ready TINYINT NOT NULL DEFAULT 0 COMMENT '仓库日汇总已完整构建并校验,含完整空版本',
finished_at_utc DATETIME(3) NULL COMMENT '本次尝试终止UTC时间', finished_at_utc DATETIME(3) NULL COMMENT '本次尝试终止UTC时间',
published_at_utc DATETIME(3) NULL COMMENT '实际原子切换发布指针UTC时间', published_at_utc DATETIME(3) NULL COMMENT '实际原子切换发布指针UTC时间',
superseded_at_utc DATETIME(3) NULL COMMENT '被新发布版替代的首次UTC时间,同事务填写且重试不重置', superseded_at_utc DATETIME(3) NULL COMMENT '被新发布版替代的首次UTC时间,同事务填写且重试不重置',
@@ -3912,6 +3913,20 @@ CREATE TABLE ll_selfop_sku_daily_sales (
CONSTRAINT chk_selfop_sku_daily_sales CHECK (outbound_quantity >= 0 AND outbound_order_count >= 0) CONSTRAINT chk_selfop_sku_daily_sales CHECK (outbound_quantity >= 0 AND outbound_order_count >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本的稀疏日事实'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本的稀疏日事实';
CREATE TABLE ll_selfop_warehouse_daily_sales (
sales_run_id BIGINT NOT NULL COMMENT '完整销量计算版本标识',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码',
business_date DATE NOT NULL COMMENT '已核准业务日',
observed_quantity DECIMAL(38,6) NULL COMMENT '该日PCS非负有效出库量之和,无有效行时为空',
invalid_row_count BIGINT NOT NULL COMMENT '该日数量为空或负数或单位非PCS的日事实行数',
source_row_count BIGINT NOT NULL COMMENT '该日参与仓库汇总的SKU日事实总行数',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
PRIMARY KEY (sales_run_id,source_account,warehouse_code,business_date),
CONSTRAINT chk_selfop_warehouse_daily_sales CHECK (source_row_count > 0 AND invalid_row_count >= 0 AND invalid_row_count <= source_row_count AND (observed_quantity IS NULL OR observed_quantity >= 0))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本按仓按日汇总,随SKU日事实批次原子构建';
CREATE TABLE ll_selfop_sku_profile ( CREATE TABLE ll_selfop_sku_profile (
id BIGINT NOT NULL COMMENT '应用生成的记录标识', id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS ll_selfop_warehouse_daily_sales (
sales_run_id BIGINT NOT NULL COMMENT '完整销量计算版本标识',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码',
business_date DATE NOT NULL COMMENT '已核准业务日',
observed_quantity DECIMAL(38,6) NULL COMMENT '该日PCS非负有效出库量之和,无有效行时为空',
invalid_row_count BIGINT NOT NULL COMMENT '该日数量为空或负数或单位非PCS的日事实行数',
source_row_count BIGINT NOT NULL COMMENT '该日参与仓库汇总的SKU日事实总行数',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
PRIMARY KEY (sales_run_id,source_account,warehouse_code,business_date),
CONSTRAINT chk_selfop_warehouse_daily_sales CHECK (source_row_count > 0 AND invalid_row_count >= 0 AND invalid_row_count <= source_row_count AND (observed_quantity IS NULL OR observed_quantity >= 0))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本按仓按日汇总,随SKU日事实批次原子构建';
SET @selfop_warehouse_daily_ready_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_selfop_sync_run' AND column_name='warehouse_daily_ready'),
'SELECT 1',
'ALTER TABLE ll_selfop_sync_run ADD COLUMN warehouse_daily_ready TINYINT NOT NULL DEFAULT 0 COMMENT ''仓库日汇总已完整构建并校验,含完整空版本''');
PREPARE selfop_warehouse_daily_ready_stmt FROM @selfop_warehouse_daily_ready_ddl;
EXECUTE selfop_warehouse_daily_ready_stmt;
DEALLOCATE PREPARE selfop_warehouse_daily_ready_stmt;
@@ -24,10 +24,10 @@ class SelfoperatedAnalyticsSchemaContractTest {
} }
@Test @Test
void sourceAndEmptyDatabaseBaselineContainIdenticalTenIndependentTables() throws Exception { void sourceAndEmptyDatabaseBaselineContainIdenticalElevenIndependentTables() throws Exception {
var source = tables(Files.readString(Path.of("src/main/resources/db/lastleg.sql"))); var source = tables(Files.readString(Path.of("src/main/resources/db/lastleg.sql")));
var deployment = tables(Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"))); var deployment = tables(Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")));
assertEquals(10, source.size()); assertEquals(11, source.size());
assertEquals(source, deployment); assertEquals(source, deployment);
for (var entry : source.entrySet()) { for (var entry : source.entrySet()) {
var ddl = entry.getValue(); var ddl = entry.getValue();
@@ -13,7 +13,7 @@ import static org.junit.jupiter.api.Assertions.*;
/** Actual mapper SQL against an offline synthetic database, not a mocked aggregate. */ /** Actual mapper SQL against an offline synthetic database, not a mocked aggregate. */
public class OutboundAggregateMapperTest { public class OutboundAggregateMapperTest {
JdbcDataSource data; SqlSession session; OutboundAggregateMapper mapper; JdbcDataSource data; SqlSessionFactory factory; SqlSession session; OutboundAggregateMapper mapper;
@BeforeEach void setup() throws Exception { @BeforeEach void setup() throws Exception {
data=new JdbcDataSource(); data.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1"); data=new JdbcDataSource(); data.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1");
sql("CREATE TABLE ll_selfop_outbound_order (id BIGINT PRIMARY KEY,source_account VARCHAR(64),customer_code VARCHAR(64),warehouse_code VARCHAR(64),sales_eligible INT,outbound_at_utc TIMESTAMP,business_date DATE,source_timezone VARCHAR(64),business_timezone VARCHAR(64),last_observation_sequence BIGINT)"); sql("CREATE TABLE ll_selfop_outbound_order (id BIGINT PRIMARY KEY,source_account VARCHAR(64),customer_code VARCHAR(64),warehouse_code VARCHAR(64),sales_eligible INT,outbound_at_utc TIMESTAMP,business_date DATE,source_timezone VARCHAR(64),business_timezone VARCHAR(64),last_observation_sequence BIGINT)");
@@ -26,9 +26,10 @@ public class OutboundAggregateMapperTest {
sql("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.selfoperated.analytics.outbound.OutboundAggregateMapperTest.jsonUnquote'"); sql("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.selfoperated.analytics.outbound.OutboundAggregateMapperTest.jsonUnquote'");
sql("CREATE TABLE ll_selfop_sku_daily_sales (id BIGINT PRIMARY KEY,created_at TIMESTAMP,updated_at TIMESTAMP,sales_run_id BIGINT,source_account VARCHAR(64),customer_code VARCHAR(64),warehouse_code VARCHAR(64),sku_code VARCHAR(128),business_date DATE,outbound_quantity DECIMAL(20,6),outbound_order_count BIGINT,quantity_unit VARCHAR(32))"); sql("CREATE TABLE ll_selfop_sku_daily_sales (id BIGINT PRIMARY KEY,created_at TIMESTAMP,updated_at TIMESTAMP,sales_run_id BIGINT,source_account VARCHAR(64),customer_code VARCHAR(64),warehouse_code VARCHAR(64),sku_code VARCHAR(128),business_date DATE,outbound_quantity DECIMAL(20,6),outbound_order_count BIGINT,quantity_unit VARCHAR(32))");
sql("CREATE TABLE ll_selfop_sku_profile (id BIGINT PRIMARY KEY,created_at TIMESTAMP,updated_at TIMESTAMP,profile_run_id BIGINT,source_account VARCHAR(64),customer_code VARCHAR(64),warehouse_code VARCHAR(64),sku_code VARCHAR(128),product_name VARCHAR(255),quantity_unit VARCHAR(32),first_seen_at_utc TIMESTAMP,earliest_evidence_at_utc TIMESTAMP,evidence_type VARCHAR(24),evidence_run_id BIGINT,last_seen_run_id BIGINT)"); sql("CREATE TABLE ll_selfop_sku_profile (id BIGINT PRIMARY KEY,created_at TIMESTAMP,updated_at TIMESTAMP,profile_run_id BIGINT,source_account VARCHAR(64),customer_code VARCHAR(64),warehouse_code VARCHAR(64),sku_code VARCHAR(128),product_name VARCHAR(255),quantity_unit VARCHAR(32),first_seen_at_utc TIMESTAMP,earliest_evidence_at_utc TIMESTAMP,evidence_type VARCHAR(24),evidence_run_id BIGINT,last_seen_run_id BIGINT)");
sql("CREATE TABLE ll_selfop_warehouse_daily_sales (sales_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),business_date DATE,observed_quantity DECIMAL(38,6),invalid_row_count BIGINT NOT NULL,source_row_count BIGINT NOT NULL,created_at TIMESTAMP,updated_at TIMESTAMP,PRIMARY KEY(sales_run_id,source_account,warehouse_code,business_date))");
Configuration config=new Configuration(new Environment("offline",new JdbcTransactionFactory(),data)); Configuration config=new Configuration(new Environment("offline",new JdbcTransactionFactory(),data));
config.setMapUnderscoreToCamelCase(true); config.setLocalCacheScope(LocalCacheScope.STATEMENT); config.addMapper(OutboundAggregateMapper.class); config.setMapUnderscoreToCamelCase(true); config.setLocalCacheScope(LocalCacheScope.STATEMENT); config.addMapper(OutboundAggregateMapper.class);
session=new SqlSessionFactoryBuilder().build(config).openSession(true); mapper=session.getMapper(OutboundAggregateMapper.class); factory=new SqlSessionFactoryBuilder().build(config);session=factory.openSession(true); mapper=session.getMapper(OutboundAggregateMapper.class);
} }
@AfterEach void close() { if(session!=null) session.close(); } @AfterEach void close() { if(session!=null) session.close(); }
void sql(String query) throws Exception {try(Connection c=data.getConnection();Statement s=c.createStatement()){s.execute(query);}} void sql(String query) throws Exception {try(Connection c=data.getConnection();Statement s=c.createStatement()){s.execute(query);}}
@@ -39,6 +40,47 @@ public class OutboundAggregateMapperTest {
sql("INSERT INTO ll_selfop_outbound_order VALUES ("+id+",'"+account+"','"+customer+"','"+warehouse+"',"+eligible+",'"+time+"','2026-10-01','Asia/Shanghai','Asia/Shanghai',"+id+")"); sql("INSERT INTO ll_selfop_outbound_order VALUES ("+id+",'"+account+"','"+customer+"','"+warehouse+"',"+eligible+",'"+time+"','2026-10-01','Asia/Shanghai','Asia/Shanghai',"+id+")");
sql("INSERT INTO ll_selfop_outbound_item VALUES ("+id+","+id+",'"+sku+"','item','PCS',"+quantity+")"); sql("INSERT INTO ll_selfop_outbound_item VALUES ("+id+","+id+",'"+sku+"','item','PCS',"+quantity+")");
} }
@Test void warehouseDailyAddsMultipleBatchesAndPreservesUnknownsAndFractionalQuantities() throws Exception {
var invalid=List.of(dailyRow(101,null,"PCS"),dailyRow(102,"-1","PCS"),dailyRow(103,"7","BOX"),dailyRow(104,"9",null));
mapper.insertDaily(invalid);mapper.insertWarehouseDaily(invalid);
assertWarehouse(null,4,4);
var first=List.of(dailyRow(105,"0.4","PCS"));mapper.insertDaily(first);mapper.insertWarehouseDaily(first);
var second=List.of(dailyRow(106,"0.4","PCS"));mapper.insertDaily(second);mapper.insertWarehouseDaily(second);
assertWarehouse("0.8",4,6);
assertEquals(6,mapper.warehouseDailySourceRows(900L,"demo","A"));assertEquals(0,mapper.warehouseDailySourceRows(900L,"demo","B"));
var moreInvalid=List.of(dailyRow(107,null,"PCS"));mapper.insertDaily(moreInvalid);mapper.insertWarehouseDaily(moreInvalid);
assertWarehouse("0.8",5,7);
}
@Test void nullOnlyBatchesRemainUnknownUntilValidZeroAppears() throws Exception {
for(long id:List.of(301L,302L)){var rows=List.of(dailyRow(id,null,"PCS"));mapper.insertDaily(rows);mapper.insertWarehouseDaily(rows);}
assertWarehouse(null,2,2);
var zero=List.of(dailyRow(303,"0","PCS"));mapper.insertDaily(zero);mapper.insertWarehouseDaily(zero);
assertWarehouse("0",2,3);
var invalid=List.of(dailyRow(304,"-1","PCS"));mapper.insertDaily(invalid);mapper.insertWarehouseDaily(invalid);
assertWarehouse("0",3,4);
}
@Test void failedBatchRollsBackBothTablesAndRetryCountsRowsOnce() throws Exception {
session.close();session=factory.openSession(false);mapper=session.getMapper(OutboundAggregateMapper.class);
var rows=List.of(dailyRow(201,"2.125","PCS"),dailyRow(202,null,"PCS"));
mapper.insertDaily(rows);mapper.insertWarehouseDaily(rows);session.rollback();
assertEquals(0,mapper.warehouseDailySourceRows(900L,"demo","A"));
mapper.insertDaily(rows);mapper.insertWarehouseDaily(rows);session.commit();
assertWarehouse("2.125",1,2);
assertThrows(RuntimeException.class,()->mapper.insertDaily(rows));session.rollback();
assertWarehouse("2.125",1,2);
}
private SelfopSkuDailySales dailyRow(long id,String quantity,String unit) {
var row=new SelfopSkuDailySales();row.setId(id);row.setSalesRunId(900L);row.setSourceAccount("demo");row.setWarehouseCode("A");
row.setCustomerCode("customer");row.setSkuCode("SKU-"+id);row.setBusinessDate(LocalDate.of(2026,10,1));
row.setOutboundQuantity(quantity==null?null:new java.math.BigDecimal(quantity));row.setQuantityUnit(unit);row.setOutboundOrderCount(1L);
row.setCreatedAt(LocalDateTime.of(2026,10,13,0,0));row.setUpdatedAt(row.getCreatedAt());return row;
}
private void assertWarehouse(String quantity,long invalid,long source) throws Exception {
try(var c=data.getConnection();var statement=c.createStatement();var result=statement.executeQuery("SELECT observed_quantity,invalid_row_count,source_row_count FROM ll_selfop_warehouse_daily_sales WHERE sales_run_id=900 AND source_account='demo' AND warehouse_code='A'")) {
assertTrue(result.next());if(quantity==null)assertNull(result.getBigDecimal(1));else assertEquals(0,new java.math.BigDecimal(quantity).compareTo(result.getBigDecimal(1)));
assertEquals(invalid,result.getLong(2));assertEquals(source,result.getLong(3));assertFalse(result.next());
}
}
@Test void aggregatesDistinctOwnersAndRetractsCancelledMigratedAndCorrectedEvidence() throws Exception { @Test void aggregatesDistinctOwnersAndRetractsCancelledMigratedAndCorrectedEvidence() throws Exception {
order(1,"demo","C1","A","SKU",1,"2026-10-01 00:00:00",2); order(1,"demo","C1","A","SKU",1,"2026-10-01 00:00:00",2);
order(2,"demo","C1","A","SKU",1,"2026-10-02 00:00:00",3); order(2,"demo","C1","A","SKU",1,"2026-10-02 00:00:00",3);
@@ -113,6 +113,10 @@ class OutboundCandidateBuildMySqlAcceptanceTest {
var cp=read(json(progress.get("candidateBuild")),OutboundCandidateTransaction.Checkpoint.class); var cp=read(json(progress.get("candidateBuild")),OutboundCandidateTransaction.Checkpoint.class);
assertEquals("DONE",cp.phase());assertEquals((long)skus*days,cp.dailyRows());assertEquals(skus,cp.profileRows()); assertEquals("DONE",cp.phase());assertEquals((long)skus*days,cp.dailyRows());assertEquals(skus,cp.profileRows());
assertEquals(cp.dailyRows(),count("ll_selfop_sku_daily_sales","sales_run_id",runId)); assertEquals(cp.dailyRows(),count("ll_selfop_sku_daily_sales","sales_run_id",runId));
assertEquals(Boolean.TRUE,saved.getWarehouseDailyReady());
assertEquals(days,count("ll_selfop_warehouse_daily_sales","sales_run_id",runId));
assertEquals(cp.dailyRows(),jdbc.queryForObject("SELECT COALESCE(SUM(source_row_count),0) FROM ll_selfop_warehouse_daily_sales WHERE sales_run_id=?",Long.class,runId));
assertEquals(0L,jdbc.queryForObject("SELECT COALESCE(SUM(invalid_row_count),0) FROM ll_selfop_warehouse_daily_sales WHERE sales_run_id=?",Long.class,runId));
assertEquals(cp.profileRows(),count("ll_selfop_sku_profile","profile_run_id",runId)); assertEquals(cp.profileRows(),count("ll_selfop_sku_profile","profile_run_id",runId));
assertEquals((skus*(long)days+499)/500+(skus+499)/500,cp.committedBatches()); assertEquals((skus*(long)days+499)/500+(skus+499)/500,cp.committedBatches());
assertEquals(pointerBefore,jdbc.queryForMap("SELECT published_run_id,published_fact_revision FROM ll_selfop_sync_scope WHERE id=?",scopeId)); assertEquals(pointerBefore,jdbc.queryForMap("SELECT published_run_id,published_fact_revision FROM ll_selfop_sync_scope WHERE id=?",scopeId));
@@ -120,6 +124,7 @@ class OutboundCandidateBuildMySqlAcceptanceTest {
assertTrue(transactionEvidence.inserts>0);assertFalse(transactionEvidence.nonTransactional); assertTrue(transactionEvidence.inserts>0);assertFalse(transactionEvidence.nonTransactional);
BigDecimal quantity=jdbc.queryForObject("SELECT SUM(outbound_quantity) FROM ll_selfop_sku_daily_sales WHERE sales_run_id=?",BigDecimal.class,runId); BigDecimal quantity=jdbc.queryForObject("SELECT SUM(outbound_quantity) FROM ll_selfop_sku_daily_sales WHERE sales_run_id=?",BigDecimal.class,runId);
assertEquals(0,BigDecimal.valueOf((long)skus*days).compareTo(quantity)); assertEquals(0,BigDecimal.valueOf((long)skus*days).compareTo(quantity));
assertEquals(0,quantity.compareTo(jdbc.queryForObject("SELECT SUM(observed_quantity) FROM ll_selfop_warehouse_daily_sales WHERE sales_run_id=?",BigDecimal.class,runId)));
report.put("dailyRows",cp.dailyRows());report.put("profileRows",cp.profileRows());report.put("committedBatches",cp.committedBatches()); report.put("dailyRows",cp.dailyRows());report.put("profileRows",cp.profileRows());report.put("committedBatches",cp.committedBatches());
report.put("aggregateInsertStatements",transactionEvidence.inserts);report.put("allAggregateWritesTransactional",true); report.put("aggregateInsertStatements",transactionEvidence.inserts);report.put("allAggregateWritesTransactional",true);
report.put("publishedPointerUnchanged",true);report.put("publishedPointerInitiallyNull",true);report.put("candidateStatus",saved.getStatus());report.put("success",true); report.put("publishedPointerUnchanged",true);report.put("publishedPointerInitiallyNull",true);report.put("candidateStatus",saved.getStatus());report.put("success",true);
@@ -65,7 +65,8 @@ class OutboundCandidateBuilderTest {
builder.build(1L,10L,"worker",2); builder.build(1L,10L,"worker",2);
verify(profiles).delete(any()); verify(aggregates,never()).insertProfiles(any()); verify(profiles).delete(any()); verify(aggregates,never()).insertProfiles(any());
verify(aggregates,never()).insertDaily(any()); verify(facts,atLeastOnce()).assertNoBarrier(scope); verify(aggregates,never()).insertDaily(any()); verify(facts,atLeastOnce()).assertNoBarrier(scope);
assertEquals(5L,run.getCalculatedFactRevision()); assertEquals("READY",run.getStatus()); assertEquals(5L,run.getCalculatedFactRevision()); assertEquals("READY",run.getStatus());assertEquals(Boolean.TRUE,run.getWarehouseDailyReady());
verify(aggregates).warehouseDailySourceRows(10L,"demo","A");
assertNotNull(run.getBuildStartedAtUtc()); assertEquals(LocalDate.of(2026,9,13),run.getCompleteDayFrom()); assertNotNull(run.getBuildStartedAtUtc()); assertEquals(LocalDate.of(2026,9,13),run.getCompleteDayFrom());
assertEquals(LocalDate.of(2026,10,12),run.getCompleteDayUntil()); assertNull(scope.getPublishedRunId()); assertEquals(LocalDate.of(2026,10,12),run.getCompleteDayUntil()); assertNull(scope.getPublishedRunId());
} }
@@ -76,16 +77,17 @@ class OutboundCandidateBuilderTest {
when(aggregates.dailyBatch("demo","A",null,null,null)).thenReturn(rows); when(aggregates.dailyBatch("demo","A",null,null,null)).thenReturn(rows);
when(aggregates.dailyBatch("demo","A","c","499",tail.getBusinessDate())).thenReturn(List.of(tail)); when(aggregates.dailyBatch("demo","A","c","499",tail.getBusinessDate())).thenReturn(List.of(tail));
when(aggregates.dailyBatch("demo","A","c","500",tail.getBusinessDate())).thenReturn(List.of()); when(aggregates.dailyBatch("demo","A","c","500",tail.getBusinessDate())).thenReturn(List.of());
when(daily.selectCount(any())).thenReturn(501L); when(daily.selectCount(any())).thenReturn(501L);when(aggregates.warehouseDailySourceRows(10L,"demo","A")).thenReturn(501L);
builder.build(1L,10L,"worker",2); builder.build(1L,10L,"worker",2);
verify(aggregates).insertDaily(rows); verify(aggregates).insertDaily(List.of(tail)); verify(aggregates).insertDaily(rows); verify(aggregates).insertDaily(List.of(tail));
verify(aggregates).insertWarehouseDaily(rows);verify(aggregates).insertWarehouseDaily(List.of(tail));
verify(daily,never()).insert(any(SelfopSkuDailySales.class)); verify(daily,never()).insert(any(SelfopSkuDailySales.class));
assertTrue(rows.stream().allMatch(r->r.getSalesRunId().equals(10L) && r.getId()!=null)); assertTrue(rows.stream().allMatch(r->r.getSalesRunId().equals(10L) && r.getId()!=null));
} }
@Test void unchangedFactsButAdvancedCoverageCannotReuseZeroSales() { @Test void unchangedFactsButAdvancedCoverageCannotReuseZeroSales() {
scope.setPublishedRunId(9L); scope.setPublishedFactRevision(5L); scope.setPublishedRunId(9L); scope.setPublishedFactRevision(5L);
SelfopSyncRun published=new SelfopSyncRun(); published.setId(9L); published.setScopeId(1L); published.setScopeHash("scope"); SelfopSyncRun published=new SelfopSyncRun(); published.setId(9L); published.setScopeId(1L); published.setScopeHash("scope");
published.setStatus("PUBLISHED"); published.setExecutionPurpose("BUSINESS"); published.setCalculatedFactRevision(5L); published.setWarehouseDailyReady(true);published.setStatus("PUBLISHED"); published.setExecutionPurpose("BUSINESS"); published.setCalculatedFactRevision(5L);
published.setBuildStartedAtUtc(SelfopSupport.utcNow().minusHours(1)); published.setBuildStartedAtUtc(SelfopSupport.utcNow().minusHours(1));
published.setScopeSnapshotJson(scope.getScopeJson()); published.setCoveredFromUtc(scope.getInitialFromUtc()); published.setScopeSnapshotJson(scope.getScopeJson()); published.setCoveredFromUtc(scope.getInitialFromUtc());
published.setCoveredUntilUtc(task.getQueryUntilUtc().minusDays(1)); when(runs.selectById(9L)).thenReturn(published); published.setCoveredUntilUtc(task.getQueryUntilUtc().minusDays(1)); when(runs.selectById(9L)).thenReturn(published);
@@ -135,4 +137,23 @@ class OutboundCandidateBuilderTest {
task.setTaskType("REBUILD"); assertTrue(OutboundCandidateBuilder.regularBuildDue(task,now,now)); task.setTaskType("REBUILD"); assertTrue(OutboundCandidateBuilder.regularBuildDue(task,now,now));
task.setTaskType("FIRST_COLLECTION"); task.setAttemptCount(2); assertTrue(OutboundCandidateBuilder.regularBuildDue(task,now,now)); task.setTaskType("FIRST_COLLECTION"); task.setAttemptCount(2); assertTrue(OutboundCandidateBuilder.regularBuildDue(task,now,now));
} }
@Test void missingWarehouseSummaryPreventsReadyAndOldReadyCannotBypassBuild() {
transaction.begin(1L,10L,"worker",2);
var row=new SelfopSkuDailySales();row.setCustomerCode("c");row.setSkuCode("sku");row.setBusinessDate(LocalDate.of(2026,10,1));
when(aggregates.dailyBatch("demo","A",null,null,null)).thenReturn(List.of(row));transaction.batch(1L,10L,"worker",2);
transaction.batch(1L,10L,"worker",2);transaction.batch(1L,10L,"worker",2);
when(daily.selectCount(any())).thenReturn(1L);
assertEquals("WAREHOUSE_DAILY_ROWS_INCOMPLETE",assertThrows(IllegalStateException.class,()->transaction.finish(1L,10L,"worker",2)).getMessage());
assertEquals("BUILDING",run.getStatus());assertEquals(Boolean.FALSE,run.getWarehouseDailyReady());
run.setStatus("READY");assertEquals("WAREHOUSE_DAILY_NOT_READY",assertThrows(IllegalStateException.class,()->transaction.begin(1L,10L,"worker",2)).getMessage());
}
@Test void warehouseBatchFailureCannotAdvanceCheckpointAndRetryUsesSameCursor() {
transaction.begin(1L,10L,"worker",2);String before=task.getProgressJson();
var row=new SelfopSkuDailySales();row.setCustomerCode("c");row.setSkuCode("sku");row.setBusinessDate(LocalDate.of(2026,10,1));
when(aggregates.dailyBatch("demo","A",null,null,null)).thenReturn(List.of(row));
when(aggregates.insertWarehouseDaily(any())).thenThrow(new IllegalStateException("SYNTHETIC_WRITE_FAILURE")).thenReturn(1);
assertThrows(IllegalStateException.class,()->transaction.batch(1L,10L,"worker",2));assertEquals(before,task.getProgressJson());
var resumed=transaction.batch(1L,10L,"worker",2);assertEquals(1,resumed.dailyRows());assertEquals(1,resumed.committedBatches());
}
} }
@@ -122,7 +122,7 @@ class AnalyticsQueryServiceTest {
@Test void missingOrReversedContinuousCoverageNeverAuthorizesFormalThirtyDayZeros(){ @Test void missingOrReversedContinuousCoverageNeverAuthorizesFormalThirtyDayZeros(){
Run r=run(11,1,SH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS","Asia/Shanghai"); Run r=run(11,1,SH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS","Asia/Shanghai");
for(LocalDateTime from:Arrays.asList(null,r.coveredUntilUtc().plusDays(1))){ for(LocalDateTime from:Arrays.asList(null,r.coveredUntilUtc().plusDays(1))){
when(mapper.publishedRun(11)).thenReturn(new Run(r.id(),r.taskId(),r.scopeId(),r.sourceAccount(),r.warehouseCode(),r.runRole(),r.scopeHash(),r.scopeSnapshotJson(),r.status(),r.coverageStatus(),from,r.coveredUntilUtc(),r.completeDayFrom(),r.completeDayUntil(),r.fetchStartedAtUtc(),r.fetchFinishedAtUtc(),r.publishedAtUtc(),r.calculatedFactRevision(),r.errorCode(),r.errorMessage(),r.supersededAtUtc(),r.executionPurpose())); when(mapper.publishedRun(11)).thenReturn(new Run(r.id(),r.taskId(),r.scopeId(),r.sourceAccount(),r.warehouseCode(),r.runRole(),r.scopeHash(),r.scopeSnapshotJson(),r.status(),r.coverageStatus(),from,r.coveredUntilUtc(),r.completeDayFrom(),r.completeDayUntil(),r.fetchStartedAtUtc(),r.fetchFinishedAtUtc(),r.publishedAtUtc(),r.calculatedFactRevision(),r.errorCode(),r.errorMessage(),r.supersededAtUtc(),r.executionPurpose(),r.warehouseDailyReady()));
var response=service.inventoryCoverage(metrics(null),options());assertNull(response.context().completeDayFrom());assertNull(response.records().get(0).outboundQuantity30d()); var response=service.inventoryCoverage(metrics(null),options());assertNull(response.context().completeDayFrom());assertNull(response.records().get(0).outboundQuantity30d());
} }
var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);verify(mapper,times(2)).inventoryPage(cap.capture(),any()); var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);verify(mapper,times(2)).inventoryPage(cap.capture(),any());
@@ -281,7 +281,7 @@ class AnalyticsQueryServiceTest {
private static Run run(long id,long scope,String hash,Instant observed,Instant superseded,String status,String purpose,String source){ private static Run run(long id,long scope,String hash,Instant observed,Instant superseded,String status,String purpose,String source){
String config="{\"businessTimezone\":\"Asia/Shanghai\",\"staleAfterSeconds\":93600,\"sourceTimezone\":"+(source==null?"null":"\""+source+"\"")+"}"; String config="{\"businessTimezone\":\"Asia/Shanghai\",\"staleAfterSeconds\":93600,\"sourceTimezone\":"+(source==null?"null":"\""+source+"\"")+"}";
return new Run(id,id+100,scope,"demo","W","WAREHOUSE_SYNC",hash,config,status,"COMPLETE",time(D.minusDays(40)),utc(observed),D.minusDays(30),D, return new Run(id,id+100,scope,"demo","W","WAREHOUSE_SYNC",hash,config,status,"COMPLETE",time(D.minusDays(40)),utc(observed),D.minusDays(30),D,
utc(observed.minusSeconds(60)),utc(observed),utc(NOW.minusSeconds(3600)),1L,null,null,superseded==null?null:utc(superseded),purpose); utc(observed.minusSeconds(60)),utc(observed),utc(NOW.minusSeconds(3600)),1L,null,null,superseded==null?null:utc(superseded),purpose,true);
} }
private static Profile profile(String unit,String type,LocalDateTime evidence){return new Profile("C","SKU","Product",unit,evidence,evidence,type,1L,1L);} private static Profile profile(String unit,String type,LocalDateTime evidence){return new Profile("C","SKU","Product",unit,evidence,evidence,type,1L,1L);}
private static Aggregate aggregate(boolean unit,String available,String quality,LocalDateTime evidence){return new Aggregate("C","SKU","Product","PCS",evidence,unit,n("5"),2L,2,1,0,n("150"),10L,n("150"),n("3"),n("120"),n(available),n("20"),n("30"),utc(NOW.minusSeconds(3600)),quality,null);} private static Aggregate aggregate(boolean unit,String available,String quality,LocalDateTime evidence){return new Aggregate("C","SKU","Product","PCS",evidence,unit,n("5"),2L,2,1,0,n("150"),10L,n("150"),n("3"),n("120"),n(available),n("20"),n("30"),utc(NOW.minusSeconds(3600)),quality,null);}
@@ -18,7 +18,7 @@ class DashboardQueryServiceTest {
var q=new AnalyticsQuery("demo","W",null,null,"TODAY",null,null,null,null,null,null,null,null,null,null,null); var q=new AnalyticsQuery("demo","W",null,null,"TODAY",null,null,null,null,null,null,null,null,null,null,null);
var context=DashboardPolicyTest.context(day.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),now,false); var context=DashboardPolicyTest.context(day.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),now,false);
var range=SalesRangePolicy.resolve("LAST_15_DAYS",null,null,"DAY",now,ZoneId.of("Asia/Shanghai"),366); var range=SalesRangePolicy.resolve("LAST_15_DAYS",null,null,"DAY",now,ZoneId.of("Asia/Shanghai"),366);
var snapshot=new AnalyticsQueryService.Snapshot(q,null,null,true,true,day.minusDays(30),day,range,context); var snapshot=new AnalyticsQueryService.Snapshot(q,salesRun(true),null,true,true,day.minusDays(30),day,range,context);
when(contexts.context(any(),eq(true),eq(false),eq(false))).thenReturn(snapshot); when(contexts.context(any(),eq(true),eq(false),eq(false))).thenReturn(snapshot);
var p=new AnalyticsSqlRequest("demo","W",null,null,11,0,day.minusDays(14),day.plusDays(1),day,day.minusDays(30),day.minusDays(30),day,day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),true,true,false,true,false,0,20,"DASHBOARD"); var p=new AnalyticsSqlRequest("demo","W",null,null,11,0,day.minusDays(14),day.plusDays(1),day,day.minusDays(30),day.minusDays(30),day,day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),true,true,false,true,false,0,20,"DASHBOARD");
when(contexts.request(snapshot,"DASHBOARD")).thenReturn(p); when(contexts.request(snapshot,"DASHBOARD")).thenReturn(p);
@@ -35,4 +35,33 @@ class DashboardQueryServiceTest {
var json=new ObjectMapper().registerModule(new JavaTimeModule()).valueToTree(result);assertTrue(json.path("context").has("salesRunId"));assertFalse(json.path("context").has("versionContext")); var json=new ObjectMapper().registerModule(new JavaTimeModule()).valueToTree(result);assertTrue(json.path("context").has("salesRunId"));assertFalse(json.path("context").has("versionContext"));
assertTrue(DashboardQueryService.class.getAnnotation(Transactional.class).readOnly()); assertTrue(DashboardQueryService.class.getAnnotation(Transactional.class).readOnly());
} }
@Test void unreadyPublishedVersionIsRejectedWhileReadyEmptyWarehouseProvesZeroAndAbsentSalesStayUnknown(){
var contexts=mock(AnalyticsQueryService.class);var mapper=mock(DashboardReadMapper.class);var service=new DashboardQueryService(contexts,mapper);
LocalDate day=DashboardPolicyTest.DAY;Instant now=DashboardPolicyTest.NOW;
var q=new AnalyticsQuery("demo","W",null,null,"TODAY",null,null,null,null,null,null,null,null,null,null,null);
var context=DashboardPolicyTest.context(day.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),now,false);
var range=SalesRangePolicy.resolve("TODAY",null,null,"DAY",now,ZoneId.of("Asia/Shanghai"),366);
for(Boolean ready:Arrays.asList(false,null)){
when(contexts.context(any(),eq(true),eq(false),eq(false))).thenReturn(new AnalyticsQueryService.Snapshot(q,salesRun(ready),null,true,true,day.minusDays(30),day,range,context));
var error=assertThrows(com.baiyue.common.core.exception.BizException.class,()->service.dashboard(q));
assertEquals("该销量版本尚未生成仓库每日汇总,请重新发布统计后再查询",error.getMessage());
}
verifyNoInteractions(mapper);
when(mapper.daily(any(),any(),any())).thenReturn(List.of());
when(mapper.report(any(),any())).thenReturn(List.of(Map.of("total_skus",0L,"hot_eligible",0L,"hot_unknown",0L)));
for(boolean salesPresent:List.of(true,false)){
var snapshot=new AnalyticsQueryService.Snapshot(q,salesPresent?salesRun(true):null,null,salesPresent,salesPresent,day.minusDays(30),day,range,context);
when(contexts.context(any(),eq(true),eq(false),eq(false))).thenReturn(snapshot);
when(contexts.request(snapshot,"DASHBOARD")).thenReturn(new AnalyticsSqlRequest("demo","W",null,null,salesPresent?11:0,0,day,day.plusDays(1),day,day.minusDays(30),day.minusDays(30),day,
day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),salesPresent,salesPresent,salesPresent,salesPresent,false,0,20,"DASHBOARD"));
var result=service.dashboard(q);
if(salesPresent){assertEquals(0,result.kpis().averageDailyOutboundQuantity30d().value().signum());assertEquals("COMPLETE",result.kpis().averageDailyOutboundQuantity30d().dataStatus());}
else {assertNull(result.kpis().averageDailyOutboundQuantity30d().value());assertEquals("UNKNOWN",result.kpis().averageDailyOutboundQuantity30d().dataStatus());}
}
}
private static AnalyticsReadRows.Run salesRun(Boolean ready){
LocalDate day=DashboardPolicyTest.DAY;LocalDateTime now=LocalDateTime.ofInstant(DashboardPolicyTest.NOW,ZoneOffset.UTC);
return new AnalyticsReadRows.Run(11L,101L,1L,"demo","W","WAREHOUSE_SYNC","a".repeat(64),"{}","PUBLISHED","COMPLETE",
day.minusDays(30).atStartOfDay().minusHours(8),now,day.minusDays(30),day,now.minusMinutes(2),now.minusMinutes(1),now,1L,null,null,null,"BUSINESS",ready);
}
} }
@@ -21,20 +21,22 @@ class DashboardSqlSemanticTest {
s.execute("CREATE ALIAS CONVERT_TZ FOR 'com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsSqlSemanticTest.convertZone'"); s.execute("CREATE ALIAS CONVERT_TZ FOR 'com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsSqlSemanticTest.convertZone'");
s.execute("CREATE TABLE ll_selfop_sku_profile(profile_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),product_name VARCHAR(255),quantity_unit VARCHAR(32),evidence_type VARCHAR(24),evidence_run_id BIGINT,earliest_evidence_at_utc TIMESTAMP)"); s.execute("CREATE TABLE ll_selfop_sku_profile(profile_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),product_name VARCHAR(255),quantity_unit VARCHAR(32),evidence_type VARCHAR(24),evidence_run_id BIGINT,earliest_evidence_at_utc TIMESTAMP)");
s.execute("CREATE TABLE ll_selfop_sku_daily_sales(id BIGINT,sales_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),business_date DATE,outbound_quantity DECIMAL(20,6),outbound_order_count BIGINT,quantity_unit VARCHAR(32))"); s.execute("CREATE TABLE ll_selfop_sku_daily_sales(id BIGINT,sales_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),business_date DATE,outbound_quantity DECIMAL(20,6),outbound_order_count BIGINT,quantity_unit VARCHAR(32))");
s.execute("CREATE TABLE ll_selfop_warehouse_daily_sales(sales_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),business_date DATE,observed_quantity DECIMAL(38,6),invalid_row_count BIGINT,source_row_count BIGINT,PRIMARY KEY(sales_run_id,source_account,warehouse_code,business_date))");
s.execute("CREATE TABLE ll_selfop_inventory_snapshot(snapshot_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),product_quality INTEGER,physical_quantity DECIMAL(20,6),available_quantity DECIMAL(20,6),frozen_quantity DECIMAL(20,6),transit_quantity DECIMAL(20,6),observed_at_utc TIMESTAMP,quality_status VARCHAR(16),quality_reason VARCHAR(64))"); s.execute("CREATE TABLE ll_selfop_inventory_snapshot(snapshot_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),product_quality INTEGER,physical_quantity DECIMAL(20,6),available_quantity DECIMAL(20,6),frozen_quantity DECIMAL(20,6),transit_quantity DECIMAL(20,6),observed_at_utc TIMESTAMP,quality_status VARCHAR(16),quality_reason VARCHAR(64))");
} }
config=new Configuration(new Environment("synthetic",new JdbcTransactionFactory(),ds));config.setMapUnderscoreToCamelCase(true);config.addMapper(DashboardReadMapper.class);config.addMapper(AnalyticsReadMapper.class); config=new Configuration(new Environment("synthetic",new JdbcTransactionFactory(),ds));config.setMapUnderscoreToCamelCase(true);config.addMapper(DashboardReadMapper.class);config.addMapper(AnalyticsReadMapper.class);
session=new SqlSessionFactoryBuilder().build(config).openSession(); session=new SqlSessionFactoryBuilder().build(config).openSession();
} }
@AfterEach void close()throws Exception{if(session!=null)session.close();if(connection!=null)connection.close();} @AfterEach void close()throws Exception{if(session!=null)session.close();if(connection!=null)connection.close();}
@Test void dailyBoundSqlPinsDateCoverWithoutChangingChartOrFixedWindowBindings(){ @Test void dailyBoundSqlUsesOnlyPinnedWarehouseSummaryWithChartAndFixedWindowBindings(){
var statement=config.getMappedStatement(DashboardReadMapper.class.getName()+".daily"); var statement=config.getMappedStatement(DashboardReadMapper.class.getName()+".daily");
for(int days:List.of(1,30,366)){ for(int days:List.of(1,30,366)){
var bound=statement.getBoundSql(Map.of("p",request(),"chartFrom",D.minusDays(days-1),"chartUntil",D.plusDays(1))); var bound=statement.getBoundSql(Map.of("p",request(),"chartFrom",D.minusDays(days-1),"chartUntil",D.plusDays(1)));
assertTrue(bound.getSql().startsWith("SELECT /*+ INDEX(ll_selfop_sku_daily_sales idx_selfop_daily_window_cover) */")); assertTrue(bound.getSql().startsWith("SELECT business_date,observed_quantity,invalid_row_count FROM ll_selfop_warehouse_daily_sales"));
assertFalse(bound.getSql().contains("ll_selfop_sku_daily_sales"));assertFalse(bound.getSql().contains("GROUP BY"));
assertEquals(List.of("p.salesRun","p.account","p.warehouse","chartFrom","chartUntil","p.from","p.until"), assertEquals(List.of("p.salesRun","p.account","p.warehouse","chartFrom","chartUntil","p.from","p.until"),
bound.getParameterMappings().stream().map(ParameterMapping::getProperty).toList()); bound.getParameterMappings().stream().map(ParameterMapping::getProperty).toList());
assertTrue(bound.getSql().endsWith("GROUP BY business_date ORDER BY business_date")); assertTrue(bound.getSql().endsWith("ORDER BY business_date"));
} }
} }
@Test void combinedReportKeepsStatisticsWhenAllThreeRankingsAreEmpty()throws Exception{ @Test void combinedReportKeepsStatisticsWhenAllThreeRankingsAreEmpty()throws Exception{
@@ -52,6 +54,8 @@ class DashboardSqlSemanticTest {
daily("A","SAME",D.minusDays(1),"7","PCS");daily("B","SAME",D.minusDays(1),"3","BOX"); daily("A","SAME",D.minusDays(1),"7","PCS");daily("B","SAME",D.minusDays(1),"3","BOX");
profile(22,"C","STOCKONLY","PCS");stock("STOCKONLY","4","4"); profile(22,"C","STOCKONLY","PCS");stock("STOCKONLY","4","4");
daily("C","SOLD",D.minusDays(2),"-1","PCS"); daily("C","SOLD",D.minusDays(2),"-1","PCS");
warehouseDaily(11,"demo","W",D.minusDays(1),"37",1,3);
warehouseDaily(11,"demo","W",D.minusDays(2),null,1,1);
for(boolean complete:List.of(false,true))for(boolean fresh:List.of(false,true)){ for(boolean complete:List.of(false,true))for(boolean fresh:List.of(false,true)){
var p=request(complete,fresh);var args=Map.<String,Object>of("p",p,"o",options()); var p=request(complete,fresh);var args=Map.<String,Object>of("p",p,"o",options());
String sql=DashboardReadSql.report(args); String sql=DashboardReadSql.report(args);
@@ -114,14 +118,73 @@ class DashboardSqlSemanticTest {
} }
@Test void warehouseDailyIncludesBothOwnersWithoutSkuObservationAndNoQualityMultiplication()throws Exception{ @Test void warehouseDailyIncludesBothOwnersWithoutSkuObservationAndNoQualityMultiplication()throws Exception{
profile(11,"A","SAME","PCS");profile(11,"B","SAME","PCS");daily("A","SAME",D.minusDays(1),"2","PCS");daily("B","SAME",D.minusDays(1),"5","PCS"); profile(11,"A","SAME","PCS");profile(11,"B","SAME","PCS");daily("A","SAME",D.minusDays(1),"2","PCS");daily("B","SAME",D.minusDays(1),"5","PCS");
warehouseDaily(11,"demo","W",D.minusDays(1),"7",0,2);
var rows=query(DashboardReadMapper.class,"daily",Map.of("p",request(),"chartFrom",D.minusDays(2),"chartUntil",D.plusDays(1))); var rows=query(DashboardReadMapper.class,"daily",Map.of("p",request(),"chartFrom",D.minusDays(2),"chartUntil",D.plusDays(1)));
assertEquals(1,rows.size());var row=(DashboardReadMapper.Day)rows.get(0);assertEquals(0,new BigDecimal("7").compareTo(row.observedQuantity()));assertEquals(0L,row.invalidRowCount()); assertEquals(1,rows.size());var row=(DashboardReadMapper.Day)rows.get(0);assertEquals(0,new BigDecimal("7").compareTo(row.observedQuantity()));assertEquals(0L,row.invalidRowCount());
} }
@Test void warehouseSummaryPinsAccountWarehouseVersionAndUnionOfBothHalfOpenWindows()throws Exception{
warehouseDaily(11,"demo","W",D.minusDays(31),"1",0,1);
warehouseDaily(11,"demo","W",D.minusDays(30),"2",0,1);
warehouseDaily(11,"demo","W",D.minusDays(1),"3",0,1);
warehouseDaily(11,"demo","W",D,"4",0,1);
warehouseDaily(11,"demo","W",D.plusDays(1),"999",0,1);
warehouseDaily(11,"other","W",D.minusDays(1),"999",0,1);
warehouseDaily(11,"demo","OTHER",D.minusDays(1),"999",0,1);
warehouseDaily(99,"demo","W",D.minusDays(1),"999",0,1);
var rows=query(DashboardReadMapper.class,"daily",Map.of("p",request(),"chartFrom",D,"chartUntil",D.plusDays(1)))
.stream().map(DashboardReadMapper.Day.class::cast).toList();
assertEquals(List.of(D.minusDays(30),D.minusDays(1),D),rows.stream().map(DashboardReadMapper.Day::businessDate).toList());
assertEquals(List.of("2","3","4"),rows.stream().map(row->row.observedQuantity().stripTrailingZeros().toPlainString()).toList());
daily("C","SKU-ONLY",D.minusDays(2),"123","PCS");
assertEquals(rows,query(DashboardReadMapper.class,"daily",Map.of("p",request(),"chartFrom",D,"chartUntil",D.plusDays(1))),"Missing summary rows must never fall back to SKU facts");
}
@Test void warehouseSummaryPreservesBadRowEvidenceAndCannotProveCompleteDays()throws Exception{
daily("A","VALID",D.minusDays(3),"7","PCS");daily("B","BOX",D.minusDays(3),"3","BOX");daily("C","NEGATIVE",D.minusDays(3),"-1","PCS");
daily("A","NO-QUANTITY",D.minusDays(2),null,"PCS");daily("B","NO-UNIT",D.minusDays(2),"5",null);
daily("A","TODAY",D,"4","PCS");
warehouseDaily(11,"demo","W",D.minusDays(3),"7",2,3);
warehouseDaily(11,"demo","W",D.minusDays(2),null,2,2);
warehouseDaily(11,"demo","W",D,"4",0,1);
var rows=query(DashboardReadMapper.class,"daily",Map.of("p",request(),"chartFrom",D.minusDays(3),"chartUntil",D.plusDays(1)))
.stream().map(DashboardReadMapper.Day.class::cast).toList();
assertEquals(originalDailyOracle(D.minusDays(3),D.plusDays(1)),rows,"Published summaries must retain the former SKU aggregation's valid subtotal and invalid count");
var covered=DashboardPolicyTest.context(D.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),DashboardPolicyTest.NOW,true);
var points=DashboardSalesPolicy.points(covered,true,D.minusDays(3),D.plusDays(1),rows);
assertEquals("PARTIAL",points.get(0).dataStatus());assertEquals(0,new BigDecimal("7").compareTo(points.get(0).observedQuantity()));assertNull(points.get(0).outboundQuantity());
assertTrue(points.get(0).reasonCodes().contains("SALES_QUANTITY_OR_UNIT_INVALID"));
assertEquals("UNKNOWN",points.get(1).dataStatus());assertNull(points.get(1).observedQuantity());
assertEquals("COMPLETE",points.get(2).dataStatus());assertEquals(BigDecimal.ZERO,points.get(2).outboundQuantity());
assertEquals("PARTIAL",points.get(3).dataStatus());assertEquals(0,new BigDecimal("4").compareTo(points.get(3).observedQuantity()));assertNull(points.get(3).outboundQuantity());
var unproved=DashboardSalesPolicy.points(DashboardPolicyTest.context(null,null,true),true,D.minusDays(3),D.plusDays(1),rows);
assertTrue(unproved.stream().allMatch(point->point.outboundQuantity()==null));
assertNull(unproved.get(2).observedQuantity());assertEquals("UNKNOWN",unproved.get(2).dataStatus());
}
@Test void warehouseSummaryReadsFractionalContributionsWithoutRounding(){
assertDoesNotThrow(()->{
daily("A","BATCH-1",D.minusDays(1),"0.4","PCS");daily("B","BATCH-2",D.minusDays(1),"0.4","PCS");
warehouseDaily(11,"demo","W",D.minusDays(1),"0.8",0,2);
var rows=query(DashboardReadMapper.class,"daily",Map.of("p",request(),"chartFrom",D.minusDays(1),"chartUntil",D));
assertEquals(originalDailyOracle(D.minusDays(1),D),rows);
assertEquals(0,new BigDecimal("0.8").compareTo(((DashboardReadMapper.Day)rows.get(0)).observedQuantity()));
});
}
/** Independent pre-summary query, retained only as a fixture oracle, never a runtime fallback. */
private List<DashboardReadMapper.Day> originalDailyOracle(LocalDate from,LocalDate until)throws Exception{
var rows=new ArrayList<DashboardReadMapper.Day>();
try(var p=connection.prepareStatement("SELECT business_date,SUM(CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN outbound_quantity END) observed_quantity,COUNT(CASE WHEN quantity_unit IS NULL OR quantity_unit<>'PCS' OR outbound_quantity IS NULL OR outbound_quantity<0 THEN 1 END) invalid_row_count FROM ll_selfop_sku_daily_sales WHERE sales_run_id=11 AND source_account='demo' AND warehouse_code='W' AND business_date>=? AND business_date<? GROUP BY business_date ORDER BY business_date")){
p.setObject(1,from);p.setObject(2,until);try(var r=p.executeQuery()){while(r.next())rows.add(new DashboardReadMapper.Day(r.getObject(1,LocalDate.class),r.getBigDecimal(2),r.getLong(3)));}
}return rows;
}
private void warehouseDaily(long run,String account,String warehouse,LocalDate day,String observed,long invalid,long sourceRows)throws Exception{
try(var p=connection.prepareStatement("INSERT INTO ll_selfop_warehouse_daily_sales VALUES(?,?,?,?,?,?,?)")){
p.setLong(1,run);p.setString(2,account);p.setString(3,warehouse);p.setObject(4,day);p.setBigDecimal(5,observed==null?null:new BigDecimal(observed));p.setLong(6,invalid);p.setLong(7,sourceRows);p.executeUpdate();
}
}
private void fixture(String sku,String physical,String available,String sold)throws Exception{profile(11,"C",sku,"PCS");profile(22,"C",sku,"PCS");stock(sku,physical,available);if(sold!=null)daily("C",sku,D.minusDays(1),sold,"PCS");} private void fixture(String sku,String physical,String available,String sold)throws Exception{profile(11,"C",sku,"PCS");profile(22,"C",sku,"PCS");stock(sku,physical,available);if(sold!=null)daily("C",sku,D.minusDays(1),sold,"PCS");}
private void profile(long run,String owner,String sku,String unit)throws Exception{try(var p=connection.prepareStatement("INSERT INTO ll_selfop_sku_profile VALUES(?,?,?,?,?,?,?,?,?,?)")){ private void profile(long run,String owner,String sku,String unit)throws Exception{try(var p=connection.prepareStatement("INSERT INTO ll_selfop_sku_profile VALUES(?,?,?,?,?,?,?,?,?,?)")){
p.setLong(1,run);p.setString(2,"demo");p.setString(3,"W");p.setString(4,owner);p.setString(5,sku);p.setString(6,sku);p.setString(7,unit);p.setString(8,run==11?"VALID_OUTBOUND":"INVENTORY_OBSERVATION");p.setLong(9,run);p.setObject(10,D.minusDays(40).atStartOfDay().minusHours(8));p.executeUpdate();}} p.setLong(1,run);p.setString(2,"demo");p.setString(3,"W");p.setString(4,owner);p.setString(5,sku);p.setString(6,sku);p.setString(7,unit);p.setString(8,run==11?"VALID_OUTBOUND":"INVENTORY_OBSERVATION");p.setLong(9,run);p.setObject(10,D.minusDays(40).atStartOfDay().minusHours(8));p.executeUpdate();}}
private void daily(String owner,String sku,LocalDate day,String value,String unit)throws Exception{try(var p=connection.prepareStatement("INSERT INTO ll_selfop_sku_daily_sales VALUES(?,?,?,?,?,?,?,?,?,?)")){ private void daily(String owner,String sku,LocalDate day,String value,String unit)throws Exception{try(var p=connection.prepareStatement("INSERT INTO ll_selfop_sku_daily_sales VALUES(?,?,?,?,?,?,?,?,?,?)")){
p.setLong(1,++sequence);p.setLong(2,11);p.setString(3,"demo");p.setString(4,"W");p.setString(5,owner);p.setString(6,sku);p.setObject(7,day);p.setBigDecimal(8,new BigDecimal(value));p.setLong(9,1);p.setString(10,unit);p.executeUpdate();}} p.setLong(1,++sequence);p.setLong(2,11);p.setString(3,"demo");p.setString(4,"W");p.setString(5,owner);p.setString(6,sku);p.setObject(7,day);p.setBigDecimal(8,value==null?null:new BigDecimal(value));p.setLong(9,1);p.setString(10,unit);p.executeUpdate();}}
private void stock(String sku,String physical,String available)throws Exception{try(var p=connection.prepareStatement("INSERT INTO ll_selfop_inventory_snapshot VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)")){ private void stock(String sku,String physical,String available)throws Exception{try(var p=connection.prepareStatement("INSERT INTO ll_selfop_inventory_snapshot VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)")){
p.setLong(1,22);p.setString(2,"demo");p.setString(3,"W");p.setString(4,"C");p.setString(5,sku);p.setInt(6,0);p.setBigDecimal(7,physical==null?null:new BigDecimal(physical));p.setBigDecimal(8,available==null?null:new BigDecimal(available));p.setBigDecimal(9,BigDecimal.ZERO);p.setBigDecimal(10,BigDecimal.ZERO);p.setObject(11,D.atStartOfDay());p.setString(12,physical==null?"UNKNOWN":"VALID");p.setString(13,physical==null?"FIELD_MISSING":null);p.executeUpdate();}} p.setLong(1,22);p.setString(2,"demo");p.setString(3,"W");p.setString(4,"C");p.setString(5,sku);p.setInt(6,0);p.setBigDecimal(7,physical==null?null:new BigDecimal(physical));p.setBigDecimal(8,available==null?null:new BigDecimal(available));p.setBigDecimal(9,BigDecimal.ZERO);p.setBigDecimal(10,BigDecimal.ZERO);p.setObject(11,D.atStartOfDay());p.setString(12,physical==null?"UNKNOWN":"VALID");p.setString(13,physical==null?"FIELD_MISSING":null);p.executeUpdate();}}
private AnalyticsSqlRequest request(){return new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(30),D,D,D.minusDays(30),D.minusDays(30),D,D.minusDays(30).atStartOfDay().minusHours(8),D.atStartOfDay(),true,true,true,true,true,0,20,"DASHBOARD");} private AnalyticsSqlRequest request(){return new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(30),D,D,D.minusDays(30),D.minusDays(30),D,D.minusDays(30).atStartOfDay().minusHours(8),D.atStartOfDay(),true,true,true,true,true,0,20,"DASHBOARD");}
@@ -26,10 +26,12 @@ class SalesRetentionServiceTest {
old.setSupersededAtUtc(SelfopSupport.utcNow().minusDays(6));assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L)); old.setSupersededAtUtc(SelfopSupport.utcNow().minusDays(6));assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));
clearInvocations(mapper); clearInvocations(mapper);
old.setSupersededAtUtc(SelfopSupport.utcNow().minusDays(8));when(mapper.deleteDaily(10L,500)).thenReturn(500);when(mapper.deleteProfiles(10L,500)).thenReturn(8); old.setSupersededAtUtc(SelfopSupport.utcNow().minusDays(8));when(mapper.deleteDaily(10L,500)).thenReturn(500);when(mapper.deleteProfiles(10L,500)).thenReturn(8);
assertEquals(new SalesRetentionService.Deleted(10L,500,8),service.deleteBatch(1L,10L)); when(mapper.deleteWarehouseDaily(10L,500)).thenReturn(30);
assertEquals(new SalesRetentionService.Deleted(10L,500,8,30),service.deleteBatch(1L,10L));
InOrder order=inOrder(mapper);order.verify(mapper).scope(1L);order.verify(mapper).run(10L); InOrder order=inOrder(mapper);order.verify(mapper).scope(1L);order.verify(mapper).run(10L);
order.verify(mapper).previous(1L,30L);order.verify(mapper).activeReferences("demo","A",10L); order.verify(mapper).previous(1L,30L);order.verify(mapper).activeReferences("demo","A",10L);
order.verify(mapper).deleteDaily(10L,500);order.verify(mapper).deleteProfiles(10L,500); order.verify(mapper).deleteDaily(10L,500);order.verify(mapper).deleteProfiles(10L,500);
order.verify(mapper).deleteWarehouseDaily(10L,500);
// Actual deletion follows scope acquisition, current-pointer/reference checks and fixed bounded predicates. // Actual deletion follows scope acquisition, current-pointer/reference checks and fixed bounded predicates.
verify(mapper).deleteDaily(10L,500);verify(mapper).deleteProfiles(10L,500); verify(mapper).deleteDaily(10L,500);verify(mapper).deleteProfiles(10L,500);
} }
@@ -40,6 +42,7 @@ class SalesRetentionServiceTest {
when(mapper.previous(1L,30L)).thenReturn(20L);when(mapper.activeReferences("demo","A",10L)).thenReturn(1L);assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L)); when(mapper.previous(1L,30L)).thenReturn(20L);when(mapper.activeReferences("demo","A",10L)).thenReturn(1L);assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));
when(mapper.activeReferences("demo","A",10L)).thenReturn(0L);old.setStatus("FAILED");assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L)); when(mapper.activeReferences("demo","A",10L)).thenReturn(0L);old.setStatus("FAILED");assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));
verify(mapper,never()).deleteDaily(anyLong(),anyInt());verify(mapper,never()).deleteProfiles(anyLong(),anyInt()); verify(mapper,never()).deleteDaily(anyLong(),anyInt());verify(mapper,never()).deleteProfiles(anyLong(),anyInt());
verify(mapper,never()).deleteWarehouseDaily(anyLong(),anyInt());
} }
@Test void pointerAndReferencesRecheckedForEveryBatch() { @Test void pointerAndReferencesRecheckedForEveryBatch() {
config.setRetentionEnabled(true);service.deleteBatch(1L,10L); config.setRetentionEnabled(true);service.deleteBatch(1L,10L);
@@ -47,6 +50,7 @@ class SalesRetentionServiceTest {
assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L)); assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));
verify(mapper,times(2)).scope(1L);verify(mapper,times(2)).activeReferences("demo","A",10L); verify(mapper,times(2)).scope(1L);verify(mapper,times(2)).activeReferences("demo","A",10L);
verify(mapper,times(1)).deleteDaily(10L,500); verify(mapper,times(1)).deleteDaily(10L,500);
verify(mapper,times(1)).deleteWarehouseDaily(10L,500);
} }
@Test void inventoryScopeAndDailyRepresentativeCannotBeCleanedAsSalesCopies() { @Test void inventoryScopeAndDailyRepresentativeCannotBeCleanedAsSalesCopies() {
config.setRetentionEnabled(true); config.setRetentionEnabled(true);
@@ -58,5 +62,16 @@ class SalesRetentionServiceTest {
assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L)); assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));
verify(mapper,never()).deleteDaily(anyLong(),anyInt()); verify(mapper,never()).deleteDaily(anyLong(),anyInt());
verify(mapper,never()).deleteProfiles(anyLong(),anyInt()); verify(mapper,never()).deleteProfiles(anyLong(),anyInt());
verify(mapper,never()).deleteWarehouseDaily(anyLong(),anyInt());
}
@Test void warehouseDailyRemainderStaysEligibleAndHasItsOwnBoundedDeletionCount() {
var configuration=new org.apache.ibatis.session.Configuration();configuration.addMapper(SalesRetentionMapper.class);
var parameters=Map.<String,Object>of("scopeId",1L,"current",30L,"previous",20L,"cutoff",SelfopSupport.utcNow(),"id",10L,"batchSize",500);
String eligible=configuration.getMappedStatement(SalesRetentionMapper.class.getName()+".eligible").getBoundSql(parameters).getSql();
assertTrue(eligible.contains("OR EXISTS (SELECT 1 FROM ll_selfop_warehouse_daily_sales w WHERE w.sales_run_id=r.id)"));
String deletion=configuration.getMappedStatement(SalesRetentionMapper.class.getName()+".deleteWarehouseDaily").getBoundSql(parameters).getSql();
assertEquals("DELETE FROM ll_selfop_warehouse_daily_sales WHERE sales_run_id=? LIMIT ?",deletion);
config.setRetentionEnabled(true);when(mapper.deleteWarehouseDaily(10L,500)).thenReturn(7);
assertEquals(new SalesRetentionService.Deleted(10L,0,0,7),service.deleteBatch(1L,10L));
} }
} }
@@ -30,7 +30,7 @@ class SelfopSalesPublisherTest {
scope.setLeaseOwner("worker"); scope.setFenceToken(3L); scope.setLeaseUntilUtc(SelfopSupport.utcNow().plusHours(1)); scope.setLeaseOwner("worker"); scope.setFenceToken(3L); scope.setLeaseUntilUtc(SelfopSupport.utcNow().plusHours(1));
scope.setFactRevision(7L); scope.setPublishedFactRevision(6L); scope.setPublishedRunId(9L); scope.setRebuildRequired(1); scope.setFactRevision(7L); scope.setPublishedFactRevision(6L); scope.setPublishedRunId(9L); scope.setRebuildRequired(1);
run=new SelfopSyncRun(); run.setExecutionPurpose("BUSINESS"); run.setAttemptNo(1); run.setSourceAccount("synthetic-account"); run.setScopeSnapshotJson("{\"quantityUnit\":\"PCS\",\"sourceTimezone\":\"UTC\"}"); run.setId(10L); run.setScopeId(1L); run.setTaskId(20L); run.setScopeHash(scope.getScopeHash()); run=new SelfopSyncRun(); run.setExecutionPurpose("BUSINESS"); run.setAttemptNo(1); run.setSourceAccount("synthetic-account"); run.setScopeSnapshotJson("{\"quantityUnit\":\"PCS\",\"sourceTimezone\":\"UTC\"}"); run.setId(10L); run.setScopeId(1L); run.setTaskId(20L); run.setScopeHash(scope.getScopeHash());
run.setFenceToken(3L); run.setRunRole("WAREHOUSE_SYNC"); run.setStatus("READY"); run.setCalculatedFactRevision(7L); run.setFenceToken(3L); run.setRunRole("WAREHOUSE_SYNC"); run.setStatus("READY"); run.setCalculatedFactRevision(7L);run.setWarehouseDailyReady(true);
run.setBuildStartedAtUtc(SelfopSupport.utcNow().minusSeconds(1));run.setFetchStartedAtUtc(SelfopSupport.utcNow().minusSeconds(2)); run.setBuildStartedAtUtc(SelfopSupport.utcNow().minusSeconds(1));run.setFetchStartedAtUtc(SelfopSupport.utcNow().minusSeconds(2));
run.setCoverageStatus("COMPLETE"); run.setCoveredFromUtc(LocalDateTime.of(2026,9,12,8,0)); run.setCoveredUntilUtc(LocalDateTime.of(2026,10,12,8,0)); run.setCoverageStatus("COMPLETE"); run.setCoveredFromUtc(LocalDateTime.of(2026,9,12,8,0)); run.setCoveredUntilUtc(LocalDateTime.of(2026,10,12,8,0));
task=new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000020"); task.setId(20L); task.setApplyState("APPLIED"); task.setStatus("RUNNING"); task.setTaskType("SCHEDULED"); task=new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000020"); task.setId(20L); task.setApplyState("APPLIED"); task.setStatus("RUNNING"); task.setTaskType("SCHEDULED");
@@ -48,6 +48,13 @@ class SelfopSalesPublisherTest {
enableSyntheticPublication(); scope.setLeaseUntilUtc(SelfopSupport.utcNow().minusSeconds(1)); enableSyntheticPublication(); scope.setLeaseUntilUtc(SelfopSupport.utcNow().minusSeconds(1));
assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)); noWrites(); assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)); noWrites();
} }
@Test void missingWarehouseDailyBuildCannotPublishOrPassIdempotentReturn() {
enableSyntheticPublication();run.setWarehouseDailyReady(false);
assertEquals("SELFOP_WAREHOUSE_DAILY_NOT_READY",assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)).getMessage());
noWrites();scope.setPublishedRunId(10L);run.setStatus("PUBLISHED");
assertEquals("SELFOP_WAREHOUSE_DAILY_NOT_READY",assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)).getMessage());
verifyNoInteractions(scopes,runs,tasks);
}
@Test void deadHalfAppliedTaskStillBlocksUnrelatedReadyCandidate() { @Test void deadHalfAppliedTaskStillBlocksUnrelatedReadyCandidate() {
enableSyntheticPublication(); task.setApplyState("BLOCKED"); task.setStatus("DEAD"); enableSyntheticPublication(); task.setApplyState("BLOCKED"); task.setStatus("DEAD");
when(locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode())).thenReturn(1L); when(locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode())).thenReturn(1L);