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/V20__procurement_exception_control.sql` 增加采购事实更正/阻断说明、异常关闭及逐来源最终去向三表。服务端保持原收货和发运记录,关闭不产生库存、退款或退货运输;需与采购详情聚合和节点阻断校验的匹配版本发布。源基线和海外统一基线同步,本文件存在不表示已执行。
@@ -31,7 +33,7 @@
| 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:|
| 国内 | `头程数据库统一增量-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,经内部接口访问 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_finished_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时间',
published_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)
) 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 (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
@@ -13,6 +13,7 @@ import java.time.LocalDateTime;
public class SelfopSyncRun {
private String executionPurpose;
private LocalDateTime buildStartedAtUtc;
private Boolean warehouseDailyReady;
private LocalDateTime supersededAtUtc;
private String progressSnapshotJson;
private LocalDateTime heartbeatAtUtc;
@@ -99,6 +99,30 @@ public interface OutboundAggregateMapper {
""")
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("""
<script>
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("APPLIED".equals(held.task().getApplyState()) || "REBUILD".equals(held.task().getTaskType()),"TASK_NOT_APPLIED");
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());
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");
@@ -44,6 +44,7 @@ public class OutboundCandidateTransaction {
}
SelfopSupport.require(run.getBuildStartedAtUtc()==null,"CANDIDATE_CHECKPOINT_MISSING");
// 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));
profiles.delete(new QueryWrapper<SelfopSkuProfile>().eq("profile_run_id",runId));
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());
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());}
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");
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(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");
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)
@@ -101,7 +103,7 @@ public class OutboundCandidateTransaction {
facts.assertNoBarrier(scope);validateFacts(scope);
if(scope.getPublishedRunId()==null)return null;
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())
|| !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;
@@ -8,7 +8,7 @@ import org.apache.ibatis.annotations.*;
public interface AnalyticsReadMapper {
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,"
+"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,"
+"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 "
@@ -19,7 +19,7 @@ public final class AnalyticsReadRows {
LocalDate completeDayFrom, LocalDate completeDayUntil,
LocalDateTime fetchStartedAtUtc, LocalDateTime fetchFinishedAtUtc,
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,
LocalDateTime firstSeenAtUtc, LocalDateTime earliestEvidenceAtUtc,
String evidenceType, Long evidenceRunId, Long lastSeenRunId) {}
@@ -18,6 +18,8 @@ public class DashboardQueryService {
public DashboardResponses.Dashboard dashboard(AnalyticsQuery input){
AnalyticsQuery q=DashboardRequest.normalize(input);
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 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");
@@ -12,12 +12,11 @@ public interface DashboardReadMapper {
record Ranked(String rankCode,String customerCode,String skuCode,String productName,BigDecimal mainValue,
BigDecimal ownerOutbound,Long orderCount,BigDecimal physicalQuantity,BigDecimal availableQuantity,
BigDecimal frozenQuantity,BigDecimal defectivePhysicalQuantity,BigDecimal outboundQuantity30d,BigDecimal coverageDays){}
// Dense 366-day EXPLAIN: the natural unique-index plan performs non-covering reads;
// the date-ordered covering index also matches warehouse daily GROUP BY/ORDER BY.
@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,"
+"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=#{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})) GROUP BY business_date ORDER BY business_date")
// Published-version warehouse facts retain valid subtotal and invalid-row evidence.
// Coverage and formal-zero proof still come from the pinned run, never row presence.
@Select("SELECT business_date,observed_quantity,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} "
+"AND ((business_date>=#{chartFrom} AND business_date<#{chartUntil}) OR (business_date>=#{p.from} AND business_date<#{p.until})) ORDER BY business_date")
List<Day> daily(@Param("p") AnalyticsSqlRequest request,@Param("chartFrom") LocalDate chartFrom,@Param("chartUntil") LocalDate chartUntil);
@SelectProvider(type=DashboardReadSql.class,method="report")
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.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)
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))
AND NOT EXISTS (
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);
@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);
@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}")
int deleteProfiles(@Param("id") Long id,@Param("batchSize") int batchSize);
}
@@ -16,7 +16,7 @@ public class SalesRetentionService {
private final SelfopAnalyticsProperties config;
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)
public Preview preview(Long scopeId,LocalDateTime now) {
@@ -46,7 +46,7 @@ public class SalesRetentionService {
&& !Objects.equals(runId,previous),"SALES_VERSION_PROTECTED");
SelfopSupport.require(mapper.activeReferences(scope.getSourceAccount(),scope.getWarehouseCode(),runId)==0,"SALES_INPUT_REFERENCED");
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) {
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);
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);
require(Boolean.TRUE.equals(run.getWarehouseDailyReady()),"SELFOP_WAREHOUSE_DAILY_NOT_READY");
if(Objects.equals(scope.getPublishedRunId(),runId)&&"PUBLISHED".equals(run.getStatus()))return;
require(scope.getSourceTimezone()!=null&&!scope.getSourceTimezone().isBlank(),"SOURCE_TIMEZONE_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");
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);
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());
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())
@@ -3771,6 +3771,7 @@ CREATE TABLE ll_selfop_sync_run (
fetch_started_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时间,用于构建耗时及首采合并间隔',
warehouse_daily_ready TINYINT NOT NULL DEFAULT 0 COMMENT '仓库日汇总已完整构建并校验,含完整空版本',
finished_at_utc DATETIME(3) NULL COMMENT '本次尝试终止UTC时间',
published_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)
) 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 (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
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
void sourceAndEmptyDatabaseBaselineContainIdenticalTenIndependentTables() throws Exception {
void sourceAndEmptyDatabaseBaselineContainIdenticalElevenIndependentTables() throws Exception {
var source = tables(Files.readString(Path.of("src/main/resources/db/lastleg.sql")));
var deployment = tables(Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")));
assertEquals(10, source.size());
assertEquals(11, source.size());
assertEquals(source, deployment);
for (var entry : source.entrySet()) {
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. */
public class OutboundAggregateMapperTest {
JdbcDataSource data; SqlSession session; OutboundAggregateMapper mapper;
JdbcDataSource data; SqlSessionFactory factory; SqlSession session; OutboundAggregateMapper mapper;
@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");
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 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_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));
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(); }
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_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 {
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);
@@ -113,6 +113,10 @@ class OutboundCandidateBuildMySqlAcceptanceTest {
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(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((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));
@@ -120,6 +124,7 @@ class OutboundCandidateBuildMySqlAcceptanceTest {
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);
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("aggregateInsertStatements",transactionEvidence.inserts);report.put("allAggregateWritesTransactional",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);
verify(profiles).delete(any()); verify(aggregates,never()).insertProfiles(any());
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());
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","c","499",tail.getBusinessDate())).thenReturn(List.of(tail));
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);
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));
assertTrue(rows.stream().allMatch(r->r.getSalesRunId().equals(10L) && r.getId()!=null));
}
@Test void unchangedFactsButAdvancedCoverageCannotReuseZeroSales() {
scope.setPublishedRunId(9L); scope.setPublishedFactRevision(5L);
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.setScopeSnapshotJson(scope.getScopeJson()); published.setCoveredFromUtc(scope.getInitialFromUtc());
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("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(){
Run r=run(11,1,SH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS","Asia/Shanghai");
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 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){
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,
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 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 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 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);
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);
@@ -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"));
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 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_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))");
}
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();
}
@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");
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)));
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"),
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{
@@ -52,6 +54,8 @@ class DashboardSqlSemanticTest {
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");
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)){
var p=request(complete,fresh);var args=Map.<String,Object>of("p",p,"o",options());
String sql=DashboardReadSql.report(args);
@@ -114,14 +118,73 @@ class DashboardSqlSemanticTest {
}
@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");
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)));
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 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();}}
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(?,?,?,?,?,?,?,?,?,?,?,?,?)")){
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");}
@@ -26,10 +26,12 @@ class SalesRetentionServiceTest {
old.setSupersededAtUtc(SelfopSupport.utcNow().minusDays(6));assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));
clearInvocations(mapper);
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);
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).deleteWarehouseDaily(10L,500);
// Actual deletion follows scope acquisition, current-pointer/reference checks and fixed bounded predicates.
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.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()).deleteWarehouseDaily(anyLong(),anyInt());
}
@Test void pointerAndReferencesRecheckedForEveryBatch() {
config.setRetentionEnabled(true);service.deleteBatch(1L,10L);
@@ -47,6 +50,7 @@ class SalesRetentionServiceTest {
assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,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)).deleteWarehouseDaily(10L,500);
}
@Test void inventoryScopeAndDailyRepresentativeCannotBeCleanedAsSalesCopies() {
config.setRetentionEnabled(true);
@@ -58,5 +62,16 @@ class SalesRetentionServiceTest {
assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));
verify(mapper,never()).deleteDaily(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.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.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.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");
@@ -48,6 +48,13 @@ class SelfopSalesPublisherTest {
enableSyntheticPublication(); scope.setLeaseUntilUtc(SelfopSupport.utcNow().minusSeconds(1));
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() {
enableSyntheticPublication(); task.setApplyState("BLOCKED"); task.setStatus("DEAD");
when(locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode())).thenReturn(1L);