feat(selfop): reconstruct historical inventory and publish qualified turnover

This commit is contained in:
2026-10-15 13:30:37 +08:00
parent d81d9cbda4
commit 99215bbb79
35 changed files with 1652 additions and 106 deletions
+3 -1
View File
@@ -30,6 +30,8 @@
客户/商品资料基线为 `lastleg-service/src/main/resources/db/selfop-master-data.sql`,仅新增 `ll_selfop_owner`、`ll_selfop_product` 两表,并同步在 Lastleg 和海外统一空库基线。按来源账号、货主编码、SKU 和完整资料版本关联,不创建平台客户、收费对象或独立映射表;未知实测尺寸保持 NULL。对 TEST 已有库只审阅并授权这两张新表的 CREATE,不能重放统一初始化文件,也不重置采集数据。 客户/商品资料基线为 `lastleg-service/src/main/resources/db/selfop-master-data.sql`,仅新增 `ll_selfop_owner`、`ll_selfop_product` 两表,并同步在 Lastleg 和海外统一空库基线。按来源账号、货主编码、SKU 和完整资料版本关联,不创建平台客户、收费对象或独立映射表;未知实测尺寸保持 NULL。对 TEST 已有库只审阅并授权这两张新表的 CREATE,不能重放统一初始化文件,也不重置采集数据。
历史库存还原基线为 `lastleg-service/src/main/resources/db/selfop-inventory-history.sql`,新增流水事实、历史日版本和日库存三表;同步 Lastleg 与海外空库基线。仅执行这三表 CREATE,不重放原始库存或销量表。匹配版本复用 `sync_task/run/stage` 的 `INVENTORY_FLOW_HISTORY`、`INVENTORY_HISTORY_BUILD` 与 `INVENTORY_FLOW/HISTORY_INPUT`,原库存快照不改写。完成三表并部署 Integration、Lastleg、Admin 后,才启用 `baiyue.self-operated-analytics.inventory-history-enabled=true`;默认关闭,历史目标30日优先再60日。保留当前账号出口、1200ms间隔和所有被历史版本引用的证据;现有销量副本淘汰不得删除这些引用。
## 唯一入口与区域边界 ## 唯一入口与区域边界
两个统一 SQL 文件仍只用于空库初始化,名称中的“增量”不表示可向已有业务库重放。已有生产库的受控结构补齐单独放在 `migrations/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。 两个统一 SQL 文件仍只用于空库初始化,名称中的“增量”不表示可向已有业务库重放。已有生产库的受控结构补齐单独放在 `migrations/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。
@@ -37,7 +39,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` | 199 | | 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 202 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 - 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
+86 -1
View File
@@ -4002,7 +4002,7 @@ CREATE TABLE ll_selfop_sync_task (
observation_baseline BIGINT NULL COMMENT '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测', observation_baseline BIGINT NULL COMMENT '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测',
request_key VARCHAR(128) NOT NULL COMMENT '共享发现包含切片/计划槽位;分仓任务包含仓库、任务族及切片或计划槽位;人工附请求幂等键', request_key VARCHAR(128) NOT NULL COMMENT '共享发现包含切片/计划槽位;分仓任务包含仓库、任务族及切片或计划槽位;人工附请求幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求类型、查询范围、范围摘要的指纹;同键不同请求拒绝', request_hash CHAR(64) NOT NULL COMMENT '请求类型、查询范围、范围摘要的指纹;同键不同请求拒绝',
task_type VARCHAR(24) NOT NULL COMMENT 'SCHEDULED、MANUAL_RETRY、BACKFILL、RECHECK、REBUILD、VALIDATION;验证不由自动计划创建', task_type VARCHAR(24) NOT NULL COMMENT '计划、补采、复核、重建与验证;INVENTORY_FLOW_HISTORY为流水片,INVENTORY_HISTORY_BUILD为本地历史还原',
planned_at_utc DATETIME(3) NULL COMMENT '计划时点;人工请求可空', planned_at_utc DATETIME(3) NULL COMMENT '计划时点;人工请求可空',
query_from_utc DATETIME(3) NULL COMMENT '出库查询区间UTC下界,库存不填', query_from_utc DATETIME(3) NULL COMMENT '出库查询区间UTC下界,库存不填',
query_until_utc DATETIME(3) NULL COMMENT '出库查询区间UTC上界,不含端点,库存不填', query_until_utc DATETIME(3) NULL COMMENT '出库查询区间UTC上界,不含端点,库存不填',
@@ -4356,3 +4356,88 @@ CREATE TABLE ll_selfop_product (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星商品资料版本'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星商品资料版本';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 199 AS initialized_tables; SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 199 AS initialized_tables;
USE baiyue_lastleg;
-- 历史库存基线;执行前必须取得指定环境的建表授权。
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_flow (
id BIGINT NOT NULL COMMENT '应用生成的流水记录标识',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码',
customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码',
sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码',
source_flow_id VARCHAR(64) NOT NULL COMMENT '来源流水字符串标识',
product_quality INT NOT NULL COMMENT '来源品质,零正品一次品,其他未知',
cell_no VARCHAR(128) NULL COMMENT '来源库位编码',
inner_batch_no VARCHAR(128) NULL COMMENT '来源内部批次号',
bill_no VARCHAR(128) NULL COMMENT '来源业务单号',
source_no VARCHAR(128) NULL COMMENT '来源关联单号',
bill_type VARCHAR(32) NULL COMMENT '来源业务类型,未验证类型不得参与正式还原',
book_qty DECIMAL(20,6) NULL COMMENT '来源记账增减量,未知为空',
book_change_qty_raw VARCHAR(64) NULL COMMENT '来源带符号变动量原文',
delta_quantity DECIMAL(20,6) NULL COMMENT '解析后的有符号实物变动量',
close_quantity DECIMAL(20,6) NULL COMMENT '对应库位批次品质链的结存',
source_create_time VARCHAR(64) NULL COMMENT '来源北京时间原文',
event_at_utc DATETIME(3) NULL COMMENT '流水发生UTC时刻,无法解析时为空',
business_date DATE NULL COMMENT '北京时间业务日期',
quantity_unit VARCHAR(16) NULL COMMENT '已确认数量单位,未知为空',
content_hash CHAR(64) NOT NULL COMMENT '当前事实内容摘要',
revision_no BIGINT NOT NULL COMMENT '同来源流水单调增加的修订号',
stage_id BIGINT NOT NULL COMMENT '当前事实对应不可变暂存证据',
first_run_id BIGINT NOT NULL COMMENT '首次观察执行尝试',
last_run_id BIGINT NOT NULL COMMENT '最近确认执行尝试',
last_observed_at DATETIME(3) NOT NULL COMMENT '最近观察UTC时刻',
validation_status VARCHAR(16) NOT NULL COMMENT '字段资格,有效或未知',
validation_reason VARCHAR(64) NULL COMMENT '数量或身份待核验原因',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_flow_source (source_account,warehouse_code,source_flow_id),
KEY idx_selfop_flow_sku (source_account,warehouse_code,customer_code,sku_code,event_at_utc),
KEY idx_selfop_flow_day (source_account,warehouse_code,business_date),
CONSTRAINT chk_selfop_flow_revision CHECK (revision_no > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='自营历史库存流水最新事实,旧版本保留于暂存证据';
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_day_version (
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 '北京时间日末所属日期',
scope_hash CHAR(64) NOT NULL COMMENT '业务范围摘要',
rule_version VARCHAR(32) NOT NULL COMMENT '日末库存还原规则版本',
build_run_id BIGINT NOT NULL COMMENT '历史重建执行尝试',
publication_group_id BIGINT NOT NULL COMMENT '必须一起切换的发布组标识',
sales_run_id BIGINT NULL COMMENT '构建时固定的销量证据版本',
anchor_run_id BIGINT NOT NULL COMMENT '固定的独立库存锚点版本',
input_hash CHAR(64) NOT NULL COMMENT '冻结流水修订与覆盖及锚点输入摘要',
evidence_json JSON NOT NULL COMMENT '固定输入清单及不可变证据引用',
status VARCHAR(16) NOT NULL COMMENT '构建中、就绪、已发布、已替代或失败',
head_slot TINYINT NULL COMMENT '当前日头为一,非当前版本为空',
valid_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '合格商品数',
unknown_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '待核验商品数',
created_at DATETIME(3) NOT NULL COMMENT '版本创建UTC时刻',
published_at DATETIME(3) NULL COMMENT '发布组提交UTC时刻',
superseded_at DATETIME(3) NULL COMMENT '被新组替代的UTC时刻',
invalidated_at DATETIME(3) NULL COMMENT '已确认输入失效的UTC时刻',
invalidation_reason VARCHAR(64) NULL COMMENT '旧版本失效原因',
rebuild_run_id BIGINT NULL COMMENT '处理失效版本的重建尝试',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_history_head (source_account,warehouse_code,business_date,head_slot),
UNIQUE KEY uk_selfop_history_build_day (build_run_id,business_date),
KEY idx_selfop_history_group (publication_group_id,status),
CONSTRAINT chk_selfop_history_head CHECK (head_slot IS NULL OR (head_slot=1 AND status='PUBLISHED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日库存不可变版本及原子发布日头';
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_daily (
day_version_id BIGINT NOT NULL COMMENT '所属历史日版本',
customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码',
sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码',
product_quality INT NOT NULL COMMENT '来源库存品质',
physical_quantity DECIMAL(20,6) NULL COMMENT '日末实物库存,未知为空,已确认零为零',
quality_status VARCHAR(16) NOT NULL COMMENT '日末资格,有效或未知',
quality_reason VARCHAR(64) NULL COMMENT '缺覆盖或锚点等未知原因',
anchor_reference BIGINT NULL COMMENT '独立库存锚点版本',
evidence_hash CHAR(64) NOT NULL COMMENT '该日计算证据摘要',
source VARCHAR(32) NOT NULL COMMENT '库存来源标记,流水还原',
PRIMARY KEY (day_version_id,customer_code,sku_code,product_quality),
KEY idx_selfop_daily_identity (customer_code,sku_code,day_version_id),
CONSTRAINT chk_selfop_daily_quantity CHECK ((quality_status='VALID' AND physical_quantity IS NOT NULL AND physical_quantity>=0) OR (quality_status='UNKNOWN' AND physical_quantity IS NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日末分货主商品品质实物库存';
@@ -87,6 +87,15 @@ public class LingxingInternalController {
tokenGuard.verify(token);return ApiResponse.success(client.queryInventoryFlow(request,context(context))); tokenGuard.verify(token);return ApiResponse.success(client.queryInventoryFlow(request,context(context)));
} }
@Operation(summary = "读取固定单仓最多一天的全部货主品质库存流水单页")
@PostMapping("/inventory-flow-page/query")
public ApiResponse<QueryPage<InventoryFlow>> inventoryFlowPage(
@RequestHeader(value="X-Internal-Service-Token",required=false) String token,
@RequestHeader(value="X-Lingxing-Egress-Context",required=false) String context,
@Valid @RequestBody InventoryFlowPageRequest request) {
tokenGuard.verify(token);return ApiResponse.success(client.queryInventoryFlowPage(request,context(context)));
}
@Operation(summary = "读取单仓全部货主、全部品质且保留零库存的产品库存单页") @Operation(summary = "读取单仓全部货主、全部品质且保留零库存的产品库存单页")
@PostMapping("/product-inventory/query") @PostMapping("/product-inventory/query")
public ApiResponse<QueryPage<ProductStock>> inventory( public ApiResponse<QueryPage<ProductStock>> inventory(
@@ -23,9 +23,12 @@ public final class LingxingWmsDtos {
public record InventoryFlowRequest(@NotBlank @Size(max=64) String warehouseCode, public record InventoryFlowRequest(@NotBlank @Size(max=64) String warehouseCode,
@NotBlank @Size(max=64) String customerCode, @NotBlank @Size(max=128) String sku, @NotBlank @Size(max=64) String customerCode, @NotBlank @Size(max=128) String sku,
@NotNull LocalDateTime startTime, @NotNull LocalDateTime endTime) { } @NotNull LocalDateTime startTime, @NotNull LocalDateTime endTime) { }
public record InventoryFlowPageRequest(@NotBlank @Size(max=64) String warehouseCode,
@NotNull LocalDateTime startTime,@NotNull LocalDateTime endTime,
@Min(1) int current,@Min(1) @Max(1000) int size) { }
public record InventoryFlow(String id, String customerCode, String whCode, String productSku, public record InventoryFlow(String id, String customerCode, String whCode, String productSku,
String cellNo, String innerBatchNo, String billNo, String billType, Integer productQuality, String cellNo, String innerBatchNo, String billNo, String billType, Integer productQuality,
BigDecimal bookQty, String bookChangQty, BigDecimal closeQty, String createTime) { } BigDecimal bookQty, String bookChangQty, BigDecimal closeQty, String createTime, String sourceNo) { }
public record Customer(String customerCode, String customerName, String status) { } public record Customer(String customerCode, String customerName, String status) { }
public record Product(String customerCode, String sku, String productName, public record Product(String customerCode, String sku, String productName,
BigDecimal length, BigDecimal width, BigDecimal height, String sizeUnit, BigDecimal length, BigDecimal width, BigDecimal height, String sizeUnit,
@@ -173,16 +173,35 @@ public final class LingxingWmsClient {
body.put("barcodeAccurate",request.sku());body.put("startTime",SOURCE_TIME.format(request.startTime())); body.put("barcodeAccurate",request.sku());body.put("startTime",SOURCE_TIME.format(request.startTime()));
body.put("endTime",SOURCE_TIME.format(request.endTime()));body.put("sortName","createTime");body.put("sortOrder","asc"); body.put("endTime",SOURCE_TIME.format(request.endTime()));body.put("sortName","createTime");body.put("sortOrder","asc");
return execute("/openapi/v2/stock/stockCellBook/page",body,context,Operation.INVENTORY_FLOW, return execute("/openapi/v2/stock/stockCellBook/page",body,context,Operation.INVENTORY_FLOW,
(data,metadata)->mappedPage(data,1,1000,"current","size",false,metadata,row->{ (data,metadata)->mappedPage(data,1,1000,"current","size",true,metadata,row->{
if(!request.warehouseCode().equals(requiredText(row,"whCode",64)) if(!request.warehouseCode().equals(requiredText(row,"whCode",64))
||!request.customerCode().equals(requiredText(row,"customerCode",64)) ||!request.customerCode().equals(requiredText(row,"customerCode",64))
||!request.sku().equals(requiredText(row,"productSku",128)))throw invalidResponse("FLOW_IDENTITY_MISMATCH",200); ||!request.sku().equals(requiredText(row,"productSku",128)))throw invalidResponse("FLOW_IDENTITY_MISMATCH",200);
JsonNode id=row.get("id"); String id=requiredText(row,"id",64);
if(id==null||!id.isIntegralNumber())throw invalidResponse("FLOW_ID_MISSING",200); return new InventoryFlow(id,request.customerCode(),request.warehouseCode(),request.sku(),
return new InventoryFlow(id.asText(),request.customerCode(),request.warehouseCode(),request.sku(),
optionalText(row,"cellNo"),optionalText(row,"innerBatchNo"),optionalText(row,"billNo"),optionalText(row,"billType"), optionalText(row,"cellNo"),optionalText(row,"innerBatchNo"),optionalText(row,"billNo"),optionalText(row,"billType"),
requiredInt(row.get("productQuality"),"PRODUCT_QUALITY"),quantity(row.get("bookQty")), requiredInt(row.get("productQuality"),"PRODUCT_QUALITY"),quantity(row.get("bookQty")),
optionalText(row,"bookChangQty"),quantity(row.get("closeQty")),optionalText(row,"createTime")); optionalText(row,"bookChangQty"),quantity(row.get("closeQty")),optionalText(row,"createTime"),optionalText(row,"sourceNo"));
}));
}
/** One fixed warehouse interval page. Scheduling and coverage belong to Lastleg. */
public QueryPage<InventoryFlow> queryInventoryFlowPage(InventoryFlowPageRequest request,EgressContext context) {
if(request==null) throw badRequest();
requireInput(request.warehouseCode(),64);validatePagination(request.current(),request.size(),1000);
if(request.startTime()==null || request.endTime()==null || !request.startTime().isBefore(request.endTime())
|| request.endTime().isAfter(request.startTime().plusDays(1))) throw badRequest();
Map<String,Object> body=pagination(request.current(),request.size());
body.put("whCode",request.warehouseCode());body.put("startTime",SOURCE_TIME.format(request.startTime()));
body.put("endTime",SOURCE_TIME.format(request.endTime()));body.put("sortName","createTime");body.put("sortOrder","asc");
return execute("/openapi/v2/stock/stockCellBook/page",body,context,Operation.INVENTORY_FLOW,
(data,metadata)->mappedPage(data,request.current(),request.size(),"current","size",true,metadata,row->{
String warehouse=requiredText(row,"whCode",64);
if(!request.warehouseCode().equals(warehouse)) throw invalidResponse("FLOW_IDENTITY_MISMATCH",200);
return new InventoryFlow(requiredText(row,"id",64),requiredText(row,"customerCode",64),warehouse,
requiredText(row,"productSku",128),optionalText(row,"cellNo"),optionalText(row,"innerBatchNo"),
optionalText(row,"billNo"),optionalText(row,"billType"),requiredInt(row.get("productQuality"),"PRODUCT_QUALITY"),
quantity(row.get("bookQty")),optionalText(row,"bookChangQty"),quantity(row.get("closeQty")),optionalText(row,"createTime"),optionalText(row,"sourceNo"));
})); }));
} }
@@ -343,7 +362,7 @@ public final class LingxingWmsClient {
} }
private PageResult orders(JsonNode data, int current, int size, String exactDeliveryNo, AttemptMetadata metadata) { private PageResult orders(JsonNode data, int current, int size, String exactDeliveryNo, AttemptMetadata metadata) {
QueryPage<OrderSummary> page = mappedPage(data, current, size, "current", "size", true, metadata, record -> { QueryPage<OrderSummary> page = mappedPage(data, current, size, "current", "size", size == 100, metadata, record -> {
String deliveryNo = requiredText(record, "deliveryNo", 128); String deliveryNo = requiredText(record, "deliveryNo", 128);
if (exactDeliveryNo != null && !exactDeliveryNo.equals(deliveryNo)) throw invalidResponse("LOCATE_DELIVERY_NO_MISMATCH", 200); if (exactDeliveryNo != null && !exactDeliveryNo.equals(deliveryNo)) throw invalidResponse("LOCATE_DELIVERY_NO_MISMATCH", 200);
int status = requiredInt(record.get("status"), "STATUS"); int status = requiredInt(record.get("status"), "STATUS");
@@ -367,7 +386,7 @@ public final class LingxingWmsClient {
} }
private <T> QueryPage<T> mappedPage(JsonNode data, int current, int size, String currentField, private <T> QueryPage<T> mappedPage(JsonNode data, int current, int size, String currentField,
String sizeField, boolean outbound, AttemptMetadata metadata, Function<JsonNode, T> projection) { String sizeField, boolean allowEmptyDefaultSize, AttemptMetadata metadata, Function<JsonNode, T> projection) {
JsonNode records = data.get("records"); JsonNode records = data.get("records");
if (records == null || !records.isArray()) throw invalidResponse("RECORDS_TYPE_" + nodeType(records), 200); if (records == null || !records.isArray()) throw invalidResponse("RECORDS_TYPE_" + nodeType(records), 200);
if (records.size() > size) throw invalidResponse("RECORDS_OVER_PAGE_SIZE", 200); if (records.size() > size) throw invalidResponse("RECORDS_OVER_PAGE_SIZE", 200);
@@ -377,7 +396,8 @@ public final class LingxingWmsClient {
int sourceSize = requiredInt(data.get(sizeField), "SIZE"); int sourceSize = requiredInt(data.get(sizeField), "SIZE");
int pages = requiredInt(data.get("pages"), "PAGES"); int pages = requiredInt(data.get("pages"), "PAGES");
if (sourceCurrent != current) throw invalidResponse("CURRENT_MISMATCH", 200); if (sourceCurrent != current) throw invalidResponse("CURRENT_MISMATCH", 200);
boolean emptyException = outbound && current == 1 && size == 100 && sourceCurrent == 1 // Verified for outbound size 100 and the bounded inventory-flow query only.
boolean emptyException = allowEmptyDefaultSize && current == 1 && sourceCurrent == 1
&& sourceSize == 10 && total == 0 && pages == 0 && records.isEmpty(); && sourceSize == 10 && total == 0 && pages == 0 && records.isEmpty();
if (sourceSize != size && !emptyException) throw invalidResponse("SIZE_MISMATCH", 200); if (sourceSize != size && !emptyException) throw invalidResponse("SIZE_MISMATCH", 200);
long expectedPages = total / size + (total % size == 0 ? 0 : 1); long expectedPages = total / size + (total % size == 0 ? 0 : 1);
@@ -51,6 +51,34 @@ x-lastleg-egress-coordination:
security: security:
- InternalServiceToken: [] - InternalServiceToken: []
paths: paths:
/internal/integrations/lingxing/wms/inventory-flow-page/query:
post:
summary: One frozen warehouse inventory-flow page
description: >-
Shared INVENTORY_FLOW permit, one upstream request, no automatic retry. Fixed warehouse and
inclusive Beijing start/end bounds at most one day, current>=1, size 1..1000, sorted by createTime asc.
Customer/SKU filters are absent. Lastleg validates persisted task/page binding, page counts,
duplicate IDs and ordering before admitting coverage. Only quantity/identity whitelist fields return.
Empty first-page current=1,size=10,total=0,pages=0,records=[] is normalized to the requested size.
parameters:
- {$ref: '#/components/parameters/LingxingEgressContext'}
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
required: [warehouseCode, startTime, endTime, current, size]
properties:
warehouseCode: {type: string, minLength: 1, maxLength: 64}
startTime: {type: string, format: date-time, description: Beijing local datetime without offset}
endTime: {type: string, format: date-time, description: Inclusive bound, at most one day after start}
current: {type: integer, minimum: 1}
size: {type: integer, minimum: 1, maximum: 1000}
responses:
'200':
description: Envelope of QueryPage with current/size/total/pages/records and source pagination evidence, or sanitized ProviderFailure. Rows retain string id, customerCode, whCode, productSku, cellNo, innerBatchNo, billNo, sourceNo, billType, productQuality, numeric bookQty/closeQty and string bookChangQty/createTime.
/internal/integrations/lingxing/wms/inventory-flow/query: /internal/integrations/lingxing/wms/inventory-flow/query:
post: post:
summary: One SKU inventory-flow diagnostic page summary: One SKU inventory-flow diagnostic page
@@ -59,6 +87,9 @@ paths:
at most 31 days, current=1 and size=1000 fixed. No automatic next page or retry. Source quality and signed at most 31 days, current=1 and size=1000 fixed. No automatic next page or retry. Source quality and signed
quantities are preserved, not translated to inventory-snapshot quality. Source 11008 only rejects this optional quantities are preserved, not translated to inventory-snapshot quality. Source 11008 only rejects this optional
operation; authentication, rate-limit and uncertain-result account protection remain effective. operation; authentication, rate-limit and uncertain-result account protection remain effective.
Source movement IDs must be strings. An empty first response with current=1, size=10, total=0,
pages=0 and records=[] is normalized to requested size=1000 while retaining sourceSize=10.
Other pagination mismatches remain errors.
parameters: parameters:
- {$ref: '#/components/parameters/LingxingEgressContext'} - {$ref: '#/components/parameters/LingxingEgressContext'}
requestBody: requestBody:
@@ -76,7 +107,7 @@ paths:
endTime: {type: string, format: date-time, description: Beijing local datetime without offset} endTime: {type: string, format: date-time, description: Beijing local datetime without offset}
responses: responses:
'200': '200':
description: Standard envelope with QueryPage or sanitized ProviderFailure. Flow rows retain id as string, customerCode, whCode, productSku, cellNo, innerBatchNo, billNo, billType, productQuality, numeric bookQty/closeQty, string bookChangQty/createTime. Unknown source fields remain null; no calculated inventory is published. description: Standard envelope with QueryPage or sanitized ProviderFailure. Flow rows retain id as string, customerCode, whCode, productSku, cellNo, innerBatchNo, billNo, sourceNo, billType, productQuality, numeric bookQty/closeQty, string bookChangQty/createTime. Unknown source fields remain null; no calculated inventory is published.
/internal/integrations/lingxing/wms/customers/query: /internal/integrations/lingxing/wms/customers/query:
post: post:
summary: Query one page of customers using the existing account egress permit summary: Query one page of customers using the existing account egress permit
@@ -22,6 +22,19 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
/** Synthetic loopback transport only; no supplier or application services. */ /** Synthetic loopback transport only; no supplier or application services. */
class LingxingWmsClientTest { class LingxingWmsClientTest {
@Test void warehouseFlowPageKeepsScopeAndUsesSharedPermit() throws Exception {
response.set(page("current","size",2,1,2,2,"{\"id\":\"F2\",\"productQuality\":0,\"customerCode\":\"C\",\"whCode\":\"W\",\"productSku\":\"SKU\",\"sourceNo\":\"SOURCE\",\"bookChangQty\":\"-1\"}"));
var request=new InventoryFlowPageRequest("W",LocalDateTime.parse("2026-10-01T00:00:00"),LocalDateTime.parse("2026-10-02T00:00:00"),2,1);
var result=client().queryInventoryFlowPage(request,context());
assertThat(result.records().get(0).sourceNo()).isEqualTo("SOURCE");
var business=mapper.readTree(body.get()).path("data");
assertThat(business.path("whCode").asText()).isEqualTo("W");
assertThat(business.has("customerCode")).isFalse();assertThat(business.has("productSku")).isFalse();
assertThat(business.path("current").asInt()).isEqualTo(2);
assertThat(completion.get().operation()).isEqualTo(Operation.INVENTORY_FLOW);
assertThrows(LingxingWmsException.class,()->client().queryInventoryFlowPage(new InventoryFlowPageRequest("W",request.startTime(),request.endTime().plusSeconds(1),1,1000),context()));
assertThat(calls.get()).isEqualTo(1);
}
@Test void customerDirectoryKeepsNamesAndUsesAccountGate() { @Test void customerDirectoryKeepsNamesAndUsesAccountGate() {
response.set(page("page","pageSize",1,1000,1,1,"{\"customerCode\":\"C\",\"customerName\":\"示例货主\",\"status\":\"0\",\"telephoneNo\":\"not-forwarded\"}")); response.set(page("page","pageSize",1,1000,1,1,"{\"customerCode\":\"C\",\"customerName\":\"示例货主\",\"status\":\"0\",\"telephoneNo\":\"not-forwarded\"}"));
var result=client().queryCustomers(new WarehouseQueryRequest(1,1000),context()); var result=client().queryCustomers(new WarehouseQueryRequest(1,1000),context());
@@ -315,8 +328,8 @@ class LingxingWmsClientTest {
LocalDateTime.of(2026,9,14,8,0),LocalDateTime.of(2026,10,14,8,0));} LocalDateTime.of(2026,9,14,8,0),LocalDateTime.of(2026,10,14,8,0));}
@Test void flowUsesExactScopeSinglePageAndPreservesSignedChangeAndLargeId() throws Exception { @Test void flowUsesExactScopeSinglePageAndPreservesSignedChangeAndLargeId() throws Exception {
response.set(page("current","size",1,1000,1,1,""" response.set(page("current","size",1,1000,1,1,"""
{"id":9223372036854775806,"customerCode":"C-DEMO","whCode":"W-DEMO","productSku":"SKU-DEMO", {"id":"9223372036854775806","customerCode":"C-DEMO","whCode":"W-DEMO","productSku":"SKU-DEMO",
"productQuality":1,"bookQty":-3,"bookChangQty":"-3","closeQty":17,"createTime":"2026-10-13 12:00:00"} "productQuality":0,"bookQty":-3,"bookChangQty":"-3","closeQty":17,"createTime":"2026-10-13 12:00:00"}
""")); """));
var result=client().queryInventoryFlow(flow(),context()); var result=client().queryInventoryFlow(flow(),context());
assertThat(path.get()).isEqualTo("/openapi/v2/stock/stockCellBook/page"); assertThat(path.get()).isEqualTo("/openapi/v2/stock/stockCellBook/page");
@@ -324,10 +337,45 @@ class LingxingWmsClientTest {
assertThat(sent.path("barcodeAccurate").asText()).isEqualTo("SKU-DEMO"); assertThat(sent.path("barcodeAccurate").asText()).isEqualTo("SKU-DEMO");
assertThat(sent.path("size").asInt()).isEqualTo(1000);assertThat(sent.has("productQuality")).isFalse(); assertThat(sent.path("size").asInt()).isEqualTo(1000);assertThat(sent.has("productQuality")).isFalse();
assertThat(result.records().get(0).id()).isEqualTo("9223372036854775806"); assertThat(result.records().get(0).id()).isEqualTo("9223372036854775806");
assertThat(result.records().get(0).productQuality()).isZero();
assertThat(result.records().get(0).bookQty()).isEqualByComparingTo("-3"); assertThat(result.records().get(0).bookQty()).isEqualByComparingTo("-3");
assertThat(result.records().get(0).bookChangQty()).isEqualTo("-3");assertThat(calls.get()).isEqualTo(1); assertThat(result.records().get(0).bookChangQty()).isEqualTo("-3");assertThat(calls.get()).isEqualTo(1);
assertThat(completion.get().operation()).isEqualTo(Operation.INVENTORY_FLOW); assertThat(completion.get().operation()).isEqualTo(Operation.INVENTORY_FLOW);
} }
@Test void flowRejectsNumericIdInsteadOfCoercingUnverifiedSourceTypes() {
response.set(page("current","size",1,1000,1,1,"""
{"id":123,"customerCode":"C-DEMO","whCode":"W-DEMO","productSku":"SKU-DEMO",
"productQuality":0,"bookQty":-1,"bookChangQty":"-1","closeQty":9}
"""));
contract(()->client().queryInventoryFlow(flow(),context()),"TEXT_FIELD_TYPE_NUMBER");
}
@Test void flowAcceptsVerifiedEmptyFirstPageAndPreservesSourcePagination() {
response.set(page("current","size",1,10,0,0,""));
var result=client().queryInventoryFlow(flow(),context());
assertThat(result.records()).isEmpty();
assertThat(result.total()).isZero();
assertThat(result.current()).isEqualTo(1);
assertThat(result.size()).isEqualTo(1000);
assertThat(result.sourceSize()).isEqualTo(10);
assertThat(calls.get()).isEqualTo(1);
assertThat(completion.get().operation()).isEqualTo(Operation.INVENTORY_FLOW);
}
@Test void flowEmptyExceptionDoesNotAcceptNonemptyOrMalformedPagination() {
var c=client();
response.set(page("current","size",1,10,1,1,"{}"));
contract(()->c.queryInventoryFlow(flow(),context()),"SIZE_MISMATCH");
response.set(page("current","size",2,10,0,0,""));
contract(()->c.queryInventoryFlow(flow(),context()),"CURRENT_MISMATCH");
response.set(page("current","size",1,10,0,1,""));
contract(()->c.queryInventoryFlow(flow(),context()),"SIZE_MISMATCH");
response.set(page("current","size",1,20,0,0,""));
contract(()->c.queryInventoryFlow(flow(),context()),"SIZE_MISMATCH");
}
@Test void flowEmptyExceptionDoesNotRelaxInventoryOrOtherOutboundSizes() {
var c=client();response.set(page("current","size",1,10,0,0,""));
contract(()->c.queryInventory(new InventoryQueryRequest("W-DEMO",1,1000),context()),"SIZE_MISMATCH");
contract(()->c.queryPage(query(1,20),context()),"SIZE_MISMATCH");
}
@Test void flowPermissionDenialIsScopedAndDoesNotStopOrdinaryQueries() { @Test void flowPermissionDenialIsScopedAndDoesNotStopOrdinaryQueries() {
var c=client();response.set("{\"code\":11008,\"msg\":\"private\"}"); var c=client();response.set("{\"code\":11008,\"msg\":\"private\"}");
var error=assertThrows(LingxingWmsException.class,()->c.queryInventoryFlow(flow(),context())); var error=assertThrows(LingxingWmsException.class,()->c.queryInventoryFlow(flow(),context()));
@@ -130,6 +130,12 @@ public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient {
"startTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(start),"endTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(end))); "startTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(start),"endTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(end)));
} }
public JsonNode inventoryFlowPage(SelfopRequestContext context,String warehouse,LocalDateTime start,LocalDateTime end,int current,int size) {
gate(context);argument(warehouse,64);paging(current,size,1000);
return post(context,"/inventory-flow-page/query",Map.of("warehouseCode",warehouse,"startTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(start),
"endTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(end),"current",current,"size",size));
}
private Summary summary(String account, JsonNode n) { private Summary summary(String account, JsonNode n) {
List<SelfopPayloads.Item> items = null; List<SelfopPayloads.Item> items = null;
JsonNode products = n.get("productList"); JsonNode products = n.get("productList");
@@ -168,10 +174,10 @@ public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient {
case "/product-inventory/query" -> Operation.PRODUCT_INVENTORY; case "/product-inventory/query" -> Operation.PRODUCT_INVENTORY;
case "/customers/query" -> Operation.CUSTOMERS; case "/customers/query" -> Operation.CUSTOMERS;
case "/products/query" -> Operation.PRODUCTS; case "/products/query" -> Operation.PRODUCTS;
case "/inventory-flow/query" -> Operation.INVENTORY_FLOW; case "/inventory-flow/query", "/inventory-flow-page/query" -> Operation.INVENTORY_FLOW;
default -> throw failure("EGRESS_OPERATION_INVALID"); default -> throw failure("EGRESS_OPERATION_INVALID");
}; };
int size = op==Operation.INVENTORY_FLOW?1000:fields.containsKey("size") ? ((Number)fields.get("size")).intValue() : 0; int size = fields.containsKey("size") ? ((Number)fields.get("size")).intValue() : op==Operation.INVENTORY_FLOW?1000:0;
String warehouse=(String)fields.get("warehouseCode"); String warehouse=(String)fields.get("warehouseCode");
String key=op==Operation.OUTBOUND_DETAIL ? SelfopSupport.json(List.of(fields.get("customerCode"),fields.get("deliveryNo"))) : null; String key=op==Operation.OUTBOUND_DETAIL ? SelfopSupport.json(List.of(fields.get("customerCode"),fields.get("deliveryNo"))) : null;
var permission = egress.reserve(context,op,size,warehouse,key,SelfopSupport.hash(fields)); var permission = egress.reserve(context,op,size,warehouse,key,SelfopSupport.hash(fields));
@@ -0,0 +1,121 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.selfoperated.analytics.client.*;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.baiyue.lastleg.selfoperated.analytics.request.*;
import com.baiyue.lastleg.selfoperated.analytics.sync.SelfopLeaseService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
/** One page per dispatch. Only the HTTP/page checkpoint holds the shared account lease. */
@Service
public class InventoryFlowCollector {
private final SelfopAnalyticsProperties config;
private final SelfopSyncTaskMapper tasks; private final SelfopSyncRunMapper runs; private final SelfopSyncStageMapper stages;
private final SelfopCoordinationMapper locks; private final SelfopLeaseService leases;
private final RestLingxingAnalyticsClient client; private final JdbcTemplate jdbc; private final TransactionTemplate tx;
private final String session=UUID.randomUUID().toString();
public InventoryFlowCollector(SelfopAnalyticsProperties config,SelfopSyncTaskMapper tasks,SelfopSyncRunMapper runs,
SelfopSyncStageMapper stages,SelfopCoordinationMapper locks,SelfopLeaseService leases,RestLingxingAnalyticsClient client,
JdbcTemplate jdbc,TransactionTemplate tx){this.config=config;this.tasks=tasks;this.runs=runs;this.stages=stages;this.locks=locks;
this.leases=leases;this.client=client;this.jdbc=jdbc;this.tx=tx;}
public record Checkpoint(String session,int nextPage,LocalDateTime lastTime) {}
public void plan(long accountId,InventoryFlowSlice slice,LocalDate round) {
String digest=hash(slice),key="continuous:history:"+round+":"+digest;
tx.executeWithoutResult(s->{
locks.lockDiscovery(accountId);
if(tasks.selectCount(new QueryWrapper<SelfopSyncTask>().eq("source_account",config.getSourceAccount()).eq("request_key",key))>0)return;
var now=utcNow();var task=new SelfopSyncTask();task.setId(IdWorker.getId());
task.setTaskNo("LXHIST-"+UUID.randomUUID().toString().replace("-",""));task.setSourceAccount(config.getSourceAccount());
task.setTaskRole("ACCOUNT_DISCOVERY");task.setDiscoveryScopeId(accountId);task.setTaskType(InventoryFlowSlice.TYPE);
task.setExecutionPurpose("BUSINESS");task.setBusinessRootTaskId(task.getId());task.setBusinessBudgetJson(SelfopBusinessBudget.freezeContinuous(now));
task.setRequestKey(key);task.setRequestHash(digest);task.setScopeHash(digest);task.setQueryScopeJson(json(slice));
task.setQueryFromUtc(LocalDateTime.ofInstant(slice.from(),ZoneOffset.UTC));task.setQueryUntilUtc(LocalDateTime.ofInstant(slice.until(),ZoneOffset.UTC));
task.setPlannedAtUtc(round.atStartOfDay());task.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);
task.setStatus("READY");task.setApplyState("NOT_APPLICABLE");task.setAttemptCount(0);task.setCreatedAt(now);task.setUpdatedAt(now);tasks.insert(task);
});
}
public boolean priorityWork() {
return !locks.dueContinuous(utcNow(),1,config.getSourceAccount()).isEmpty();
}
public void page() {
if(priorityWork())return;
var pending=tasks.selectList(new QueryWrapper<SelfopSyncTask>().eq("source_account",config.getSourceAccount())
.eq("task_type",InventoryFlowSlice.TYPE).in("status",List.of("READY","RUNNING","RETRY_WAIT"))
.and(q->q.isNull("next_retry_at_utc").or().le("next_retry_at_utc",utcNow())).orderByDesc("query_until_utc").orderByAsc("id").last("LIMIT 1"));
if(pending.isEmpty())return;
var selected=pending.get(0);var lease=leases.acquireDiscovery(selected.getDiscoveryScopeId(),"history-"+session);
try {
if(priorityWork())return;
var run=tx.execute(s->{
leases.verify(lease);var task=locks.lockTask(selected.getId());
require(Set.of("READY","RUNNING","RETRY_WAIT").contains(task.getStatus()),"HISTORY_TASK_NOT_READY");
var prior=runs.selectOne(new QueryWrapper<SelfopSyncRun>().eq("task_id",task.getId()).orderByDesc("attempt_no").last("LIMIT 1"));
if(prior!=null&&"FETCHING".equals(prior.getStatus())&&prior.getProgressSnapshotJson()!=null
&&session.equals(read(prior.getProgressSnapshotJson(),Checkpoint.class).session())) {
prior.setFenceToken(lease.fence());runs.updateById(prior);return prior;
}
if(prior!=null&&"FETCHING".equals(prior.getStatus())) {
prior.setStatus("ABORTED");prior.setErrorCode("HISTORY_RESTART_PAGE_ONE");prior.setFinishedAtUtc(utcNow());runs.updateById(prior);
}
if(task.getAttemptCount()>=config.getMaxAttempts()){task.setStatus("DEAD");tasks.updateById(task);return null;}
var now=utcNow();var next=new SelfopSyncRun();next.setId(IdWorker.getId());next.setTaskId(task.getId());
next.setAttemptNo(task.getAttemptCount()+1);next.setSourceAccount(task.getSourceAccount());next.setExecutionPurpose("BUSINESS");
next.setRunRole("ACCOUNT_DISCOVERY");next.setScopeHash(task.getScopeHash());next.setScopeSnapshotJson(task.getQueryScopeJson());
next.setFenceToken(lease.fence());next.setStatus("FETCHING");next.setLastPhase("FETCHING");next.setCoverageStatus("UNKNOWN");
next.setPageCount(0);next.setUniqueObjectCount(0L);next.setFetchStartedAtUtc(now);next.setCreatedAt(now);next.setUpdatedAt(now);
next.setProgressSnapshotJson(json(new Checkpoint(session,1,null)));runs.insert(next);
task.setStatus("RUNNING");task.setAttemptCount(next.getAttemptNo());task.setUpdatedAt(now);tasks.updateById(task);return next;
});
if(run==null)return;
var task=tasks.selectById(selected.getId());var slice=read(task.getQueryScopeJson(),InventoryFlowSlice.class);
var checkpoint=read(run.getProgressSnapshotJson(),Checkpoint.class);
try {
var page=slice.validate(client.inventoryFlowPage(SelfopRequestContext.forTask(task,run,config),slice.warehouse(),slice.start(),slice.end(),checkpoint.nextPage(),slice.size()),
checkpoint.nextPage(),run.getSourceTotalCount(),checkpoint.lastTime());
tx.executeWithoutResult(s->{
leases.verify(lease);var current=locks.lockTask(task.getId());require("RUNNING".equals(current.getStatus()),"HISTORY_TASK_STOPPED");
var now=utcNow();
for(var row:page.rows()) {
var stage=new SelfopSyncStage();stage.setId(IdWorker.getId());stage.setRunId(run.getId());stage.setStageBatchId(run.getId());
stage.setLastFetchRunId(run.getId());stage.setWarehouseCode(slice.warehouse());stage.setObjectType("INVENTORY_FLOW");
stage.setObjectKeyHash(hash(row.path("id").asText()));stage.setContentHash(hash(row));stage.setPayloadJson(json(row));
stage.setFirstPageNo(checkpoint.nextPage());stage.setObservedAtUtc(now);stage.setValidationStatus("VALID");stage.setCreatedAt(now);stage.setUpdatedAt(now);
stages.insert(stage); // Duplicate IDs across pages roll back this entire page.
}
run.setPageCount(checkpoint.nextPage());run.setSourceTotalCount(page.total());run.setUniqueObjectCount(run.getUniqueObjectCount()+page.rows().size());
run.setProgressSnapshotJson(json(new Checkpoint(session,checkpoint.nextPage()+1,page.lastTime())));run.setUpdatedAt(now);run.setHeartbeatAtUtc(now);
if(checkpoint.nextPage()>=page.pages()) {
require(run.getUniqueObjectCount()==page.total(),"HISTORY_PAGE_COUNT_MISMATCH");
run.setStatus("VALIDATED");run.setLastPhase("VALIDATED");run.setFetchFinishedAtUtc(now);
// Coverage becomes usable only after the separate local merge finishes.
current.setStatus("WAITING_SOURCE");
}
runs.updateById(run);current.setProgressUpdatedAtUtc(now);current.setUpdatedAt(now);tasks.updateById(current);
});
}catch(EgressRejectedException e) {
tx.executeWithoutResult(s->{leases.verify(lease);jdbc.update("UPDATE ll_selfop_sync_task SET next_retry_at_utc=?,updated_at=? WHERE id=? AND status='RUNNING'",
e.waitUntilUtc()==null?utcNow().plusSeconds(30):e.waitUntilUtc(),utcNow(),task.getId());});
}catch(RuntimeException e) {
tx.executeWithoutResult(s->{leases.verify(lease);run.setStatus("FAILED");run.setErrorCode("HISTORY_FETCH_FAILED");
run.setErrorMessage("分页失败,完整片重试;账号不确定结果仍受出口暂停保护");run.setFinishedAtUtc(utcNow());runs.updateById(run);
boolean drift="HISTORY_PAGE_DRIFT_OR_INVALID".equals(e.getMessage())||e instanceof org.springframework.dao.DuplicateKeyException;
long seconds=Duration.between(slice.start(),slice.end()).getSeconds();
if(drift&&seconds>3600) {
var midpoint=slice.start().plusSeconds(seconds/2);
plan(task.getDiscoveryScopeId(),new InventoryFlowSlice(slice.warehouse(),slice.start(),midpoint,1000),task.getPlannedAtUtc().toLocalDate());
plan(task.getDiscoveryScopeId(),new InventoryFlowSlice(slice.warehouse(),midpoint,slice.end(),1000),task.getPlannedAtUtc().toLocalDate());
}
jdbc.update("UPDATE ll_selfop_sync_task SET status=?,next_retry_at_utc=?,updated_at=? WHERE id=? AND status='RUNNING'",
drift&&seconds>3600||task.getAttemptCount()>=config.getMaxAttempts()?"DEAD":"RETRY_WAIT",utcNow().plusSeconds(60),utcNow(),task.getId());});
}
}finally{leases.release(lease);}
}
}
@@ -0,0 +1,91 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.*;
import java.util.*;
/** Small local transactions; facts and invalidation commit together, independently of HTTP. */
@Repository
public class InventoryFlowFacts {
private final JdbcTemplate jdbc;private final TransactionTemplate tx;private final InventoryHistoryVersions versions;
public InventoryFlowFacts(JdbcTemplate jdbc,TransactionTemplate tx,InventoryHistoryVersions versions){this.jdbc=jdbc;this.tx=tx;this.versions=versions;}
public boolean mergeNext(String account) {
var pending=jdbc.queryForList("SELECT r.id,t.id task_id,t.query_scope_json FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE t.source_account=? AND t.task_type=? AND t.status='WAITING_SOURCE' AND r.status='VALIDATED' ORDER BY r.id LIMIT 1",account,InventoryFlowSlice.TYPE);
if(pending.isEmpty())return false;
long run=((Number)pending.get(0).get("id")).longValue(),task=((Number)pending.get(0).get("task_id")).longValue();
var slice=SelfopSupport.read(string(pending.get(0).get("query_scope_json")),InventoryFlowSlice.class);
tx.executeWithoutResult(s->{
versions.lockWarehouse(account,slice.warehouse());
var state=jdbc.queryForMap("SELECT status,COALESCE(applied_fact_revision,0) checkpoint FROM ll_selfop_sync_run WHERE id=? FOR UPDATE",run);
if(!"VALIDATED".equals(state.get("status")))return;
long after=((Number)state.get("checkpoint")).longValue();
var batch=jdbc.queryForList("SELECT id,payload_json,content_hash,observed_at_utc FROM ll_selfop_sync_stage WHERE run_id=? AND object_type='INVENTORY_FLOW' AND id>? ORDER BY id LIMIT 500",run,after);
boolean changed=false;
for(var stage:batch)changed|=merge(account,slice,run,stage);
if(changed)versions.invalidate(account,slice.warehouse(),run,"FLOW_FACT_REVISED");
if(!batch.isEmpty()) {
jdbc.update("UPDATE ll_selfop_sync_run SET applied_fact_revision=?,updated_at=? WHERE id=?",batch.get(batch.size()-1).get("id"),now(),run);return;
}
// A previously observed row disappearing is uncertainty, not an invented reversal.
int missing=jdbc.update("UPDATE ll_selfop_inventory_flow SET validation_status='UNKNOWN',validation_reason='SOURCE_FLOW_MISSING',revision_no=revision_no+1 WHERE source_account=? AND warehouse_code=? AND event_at_utc>=? AND event_at_utc<? AND validation_reason IS NULL AND last_run_id<>? AND last_observed_at<(SELECT fetch_started_at_utc FROM ll_selfop_sync_run WHERE id=?) LIMIT 100",
account,slice.warehouse(),utc(slice.from()),utc(slice.until()),run,run);
if(missing>0){versions.invalidate(account,slice.warehouse(),run,"SOURCE_FLOW_MISSING");return;}
jdbc.update("UPDATE ll_selfop_sync_run SET status='FACTS_APPLIED',last_phase='FACTS_APPLIED',coverage_status='COMPLETE',covered_from_utc=?,covered_until_utc=?,finished_at_utc=?,updated_at=? WHERE id=?",
utc(slice.from()),utc(slice.until()),now(),now(),run);
jdbc.update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED',updated_at=? WHERE id=? AND status='WAITING_SOURCE'",now(),task);
});
return true;
}
private boolean merge(String account,InventoryFlowSlice slice,long run,Map<String,Object> stage) {
var row=SelfopSupport.read(string(stage.get("payload_json")),JsonNode.class);String id=row.path("id").asText();
var old=jdbc.queryForList("SELECT id,customer_code,sku_code,product_quality,content_hash,revision_no,validation_reason,last_observed_at FROM ll_selfop_inventory_flow WHERE source_account=? AND warehouse_code=? AND source_flow_id=?",account,slice.warehouse(),id);
var observed=instant(stage.get("observed_at_utc"));
if(!old.isEmpty()&&instant(old.get(0).get("last_observed_at")).isAfter(observed))return false;
if(!old.isEmpty()) {
var prior=old.get(0);
if("FLOW_IDENTITY_CONFLICT".equals(prior.get("validation_reason")))return false;
if(!Objects.equals(prior.get("customer_code"),row.path("customerCode").asText())
||!Objects.equals(prior.get("sku_code"),row.path("productSku").asText())
||((Number)prior.get("product_quality")).intValue()!=row.path("productQuality").asInt(-1)) {
jdbc.update("UPDATE ll_selfop_inventory_flow SET validation_status='UNKNOWN',validation_reason='FLOW_IDENTITY_CONFLICT',revision_no=revision_no+1 WHERE id=?",prior.get("id"));
return true;
}
}
if(!old.isEmpty()&&Objects.equals(old.get(0).get("content_hash"),stage.get("content_hash"))&&old.get(0).get("validation_reason")==null){
jdbc.update("UPDATE ll_selfop_inventory_flow SET last_run_id=?,last_observed_at=? WHERE id=?",run,utc(observed),old.get(0).get("id"));return false;
}
var fields=new LinkedHashMap<String,Object>();
fields.put("source_account",account);fields.put("warehouse_code",slice.warehouse());fields.put("source_flow_id",id);
fields.put("customer_code",row.path("customerCode").asText());fields.put("sku_code",row.path("productSku").asText());fields.put("product_quality",row.path("productQuality").asInt(-1));
for(var name:Map.of("cell_no","cellNo","inner_batch_no","innerBatchNo","bill_no","billNo","source_no","sourceNo","bill_type","billType").entrySet())fields.put(name.getKey(),nullable(row,name.getValue()));
var at=LocalDateTime.parse(row.path("createTime").asText().replace(' ','T'));
fields.put("book_qty",number(row,"bookQty"));fields.put("book_change_qty_raw",nullable(row,"bookChangQty"));fields.put("delta_quantity",number(row,"bookChangQty"));fields.put("close_quantity",number(row,"closeQty"));
fields.put("source_create_time",row.path("createTime").asText());fields.put("event_at_utc",at.minusHours(8));fields.put("business_date",at.toLocalDate());fields.put("quantity_unit","PCS");
fields.put("content_hash",stage.get("content_hash"));fields.put("stage_id",stage.get("id"));fields.put("last_run_id",run);fields.put("last_observed_at",utc(observed));
fields.put("validation_status","VALID");fields.put("validation_reason",null);
fields.put("revision_no",old.isEmpty()?1L:((Number)old.get(0).get("revision_no")).longValue()+1);
var named=new NamedParameterJdbcTemplate(jdbc);
if(old.isEmpty()) {
fields.put("id",IdWorker.getId());fields.put("first_run_id",run);
named.update("INSERT INTO ll_selfop_inventory_flow ("+String.join(",",fields.keySet())+") VALUES (:"+String.join(",:",fields.keySet())+")",fields);
}else{
String sets=String.join(",",fields.keySet().stream().map(k->k+"=:"+k).toList());fields.put("id",old.get(0).get("id"));
named.update("UPDATE ll_selfop_inventory_flow SET "+sets+" WHERE id=:id",fields);
}
return true;
}
static BigDecimal number(JsonNode row,String field){try{var v=new BigDecimal(row.path(field).asText());return v.scale()<=6&&v.precision()-v.scale()<=14?v:null;}catch(RuntimeException e){return null;}}
static String nullable(JsonNode row,String field){return row.path(field).isNull()||row.path(field).isMissingNode()?null:row.path(field).asText();}
static String string(Object value){return value instanceof byte[] b?new String(b,java.nio.charset.StandardCharsets.UTF_8):String.valueOf(value);}
static Instant instant(Object v){return v instanceof Timestamp t?t.toLocalDateTime().toInstant(ZoneOffset.UTC):((LocalDateTime)v).toInstant(ZoneOffset.UTC);}
static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC);}
private static LocalDateTime utc(Instant instant){return LocalDateTime.ofInstant(instant,ZoneOffset.UTC);}
}
@@ -0,0 +1,54 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport;
import com.fasterxml.jackson.databind.JsonNode;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
/** Fixed inclusive provider bounds; persisted coverage is half open at the next second. */
public record InventoryFlowSlice(String warehouse, LocalDateTime start, LocalDateTime end, int size) {
public static final String TYPE="INVENTORY_FLOW_HISTORY";
public InventoryFlowSlice {
if(warehouse==null||warehouse.isBlank()||warehouse.length()>64||start==null||end==null
||!start.isBefore(end)||end.isAfter(start.plusDays(1))||start.getNano()!=0||end.getNano()!=0||size!=1000)
throw new IllegalArgumentException("HISTORY_SLICE_INVALID");
}
public Map<String,Object> request(int page) {
if(page<1)throw new IllegalArgumentException("HISTORY_PAGE_INVALID");
return Map.of("warehouseCode",warehouse,"startTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(start),
"endTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(end),"current",page,"size",size);
}
public String requestHash(int page){return SelfopSupport.hash(request(page));}
public Instant from(){return start.toInstant(ZoneOffset.ofHours(8));}
public Instant until(){return end.plusSeconds(1).toInstant(ZoneOffset.ofHours(8));}
public record Page(long total,long pages,LocalDateTime lastTime,List<JsonNode> rows) {}
public Page validate(JsonNode response,int page,Long previousTotal,LocalDateTime previousLast) {
if(response==null||!response.path("records").isArray()||response.path("current").asInt()!=page
||response.path("size").asInt()!=size)throw invalid();
// Internal Long fields are serialized as decimal strings by LongIdJacksonConfiguration.
var totalNode=response.path("total");
if(!(totalNode.isIntegralNumber()||totalNode.isTextual())||!totalNode.asText().matches("[0-9]{1,19}"))throw invalid();
long total;
try{total=Long.parseLong(totalNode.asText());}catch(NumberFormatException e){throw invalid();}
long pages=total==0?0:(total-1)/size+1;
if(total<0||pages>Integer.MAX_VALUE||response.path("pages").asLong(-1)!=pages
||previousTotal!=null&&total!=previousTotal||page>Math.max(1,pages)
||response.path("records").size()!=Math.min(size,Math.max(0,total-(long)(page-1)*size)))throw invalid();
var ids=new HashSet<String>();var rows=new ArrayList<JsonNode>();var last=previousLast;
for(var row:response.path("records")) {
String id=text(row,"id",64);text(row,"customerCode",64);text(row,"productSku",128);
if(!warehouse.equals(text(row,"whCode",64))||!ids.add(id))throw invalid();
LocalDateTime at;
try{at=LocalDateTime.parse(row.path("createTime").asText().replace(' ','T'));}catch(RuntimeException e){throw invalid();}
if(at.isBefore(start)||at.isAfter(end)||last!=null&&at.isBefore(last))throw invalid();
last=at;rows.add(row);
}
return new Page(total,pages,last,List.copyOf(rows));
}
static String text(JsonNode row,String field,int max) {
var value=row.path(field);String text=value.asText();
if(!value.isTextual()||text.isBlank()||text.length()>max)throw invalid();return text;
}
private static IllegalStateException invalid(){return new IllegalStateException("HISTORY_PAGE_DRIFT_OR_INVALID");}
}
@@ -0,0 +1,186 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.history.InventoryHistoryCalculator.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
/** Local reconstruction worker. Source requests and account leases are deliberately absent. */
@Service
public class InventoryHistoryBuilder {
public static final String TYPE="INVENTORY_HISTORY_BUILD";
private final JdbcTemplate jdbc;private final TransactionTemplate tx;private final InventoryHistoryVersions versions;
private final SelfopSyncTaskMapper tasks;private final SelfopSyncRunMapper runs;private final SelfopSyncStageMapper stages;
public InventoryHistoryBuilder(JdbcTemplate jdbc,TransactionTemplate tx,InventoryHistoryVersions versions,
SelfopSyncTaskMapper tasks,SelfopSyncRunMapper runs,SelfopSyncStageMapper stages){this.jdbc=jdbc;this.tx=tx;this.versions=versions;this.tasks=tasks;this.runs=runs;this.stages=stages;}
public record StockRun(long id,String scopeHash,Instant from,Instant until) {}
public StockRun anchor(String account,String warehouse,int offset) {
var rows=jdbc.query("SELECT r.id,r.scope_hash,r.fetch_started_at_utc,r.fetch_finished_at_utc FROM ll_selfop_sync_run r JOIN ll_selfop_sync_scope s ON s.id=r.scope_id AND s.scope_hash=r.scope_hash WHERE s.source_account=? AND s.warehouse_code=? AND s.data_family='INVENTORY' AND r.status='PUBLISHED' AND r.execution_purpose='BUSINESS' AND r.coverage_status='COMPLETE' AND r.source_total_count=r.unique_object_count AND r.page_count>0 AND r.fetch_started_at_utc<=r.fetch_finished_at_utc ORDER BY r.published_at_utc DESC,r.id DESC LIMIT 1 OFFSET ?",
(rs,n)->new StockRun(rs.getLong(1),rs.getString(2),rs.getTimestamp(3).toLocalDateTime().toInstant(ZoneOffset.UTC),rs.getTimestamp(4).toLocalDateTime().toInstant(ZoneOffset.UTC)),account,warehouse,offset);
return rows.isEmpty()?null:rows.get(0);
}
private record Frozen(InventoryHistoryVersions.Build build,List<InventoryHistoryVersions.Candidate> candidates,
long task,StockRun anchor,StockRun reference,LocalDate from,LocalDate until,List<Interval> coverage,List<Long> coverageRuns) {}
public void build(String account,String warehouse,int days) {
Frozen frozen=tx.execute(s->freeze(account,warehouse,Math.max(30,Math.min(60,days))));
if(frozen==null)return;
try {
long count=0;String owner="",sku="";
while(true) {
// Keyset paging bounds memory independently of the warehouse's SKU count.
var identities=candidates(frozen,owner,sku);
if(identities.isEmpty())break;
var dailyBatches=new HashMap<LocalDate,List<Daily>>();
for(var identity:identities) {
var input=input(frozen,identity);var daily=reconstruct(input);
String crosscheck=crosscheck(input.anchors(),referenceAnchors(frozen,identity),input.flows(),frozen.coverage());
if(crosscheck!=null)daily=daily.stream().map(d->new Daily(d.date(),d.key(),null,"UNKNOWN",crosscheck,d.anchorRun())).toList();
var evidence=flowEvidence(frozen,identity);
String evidenceHash=hash(evidence);
tx.executeWithoutResult(s->{
requireBuilding(frozen);saveInput(frozen,identity,evidence,evidenceHash);
});
for(var d:daily)dailyBatches.computeIfAbsent(d.date(),k->new ArrayList<>()).add(d);
owner=identity.owner();sku=identity.sku();count++;
}
for(var candidate:frozen.candidates()) {
var rows=dailyBatches.getOrDefault(candidate.date(),List.of());
for(int start=0;start<rows.size();start+=500)versions.append(candidate,rows.subList(start,Math.min(rows.size(),start+500)),hash(List.of(frozen.build().run(),candidate.date())));
}
heartbeat(frozen);
}
for(var c:frozen.candidates())versions.ready(c,count);
tx.executeWithoutResult(s->{
versions.lockWarehouse(account,warehouse);requireBuilding(frozen);
versions.publish(frozen.build(),frozen.candidates());
jdbc.update("UPDATE ll_selfop_sync_run SET status='FACTS_APPLIED',last_phase='HISTORY_PUBLISHED',published_at_utc=?,finished_at_utc=?,updated_at=? WHERE id=?",utcNow(),utcNow(),utcNow(),frozen.build().run());
jdbc.update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED',result_published_run_id=?,updated_at=? WHERE id=?",frozen.build().run(),utcNow(),frozen.task());
});
}catch(RuntimeException failure) {
tx.executeWithoutResult(s->{
versions.lockWarehouse(account,warehouse);
jdbc.update("UPDATE ll_selfop_inventory_day_version SET status='FAILED' WHERE build_run_id=? AND status IN ('BUILDING','READY')",frozen.build().run());
jdbc.update("UPDATE ll_selfop_sync_run SET status='FAILED',error_code='HISTORY_BUILD_FAILED',error_message='输入变化或构建失败,保留候选证据等待下轮重新构建',finished_at_utc=?,updated_at=? WHERE id=? AND status='BUILDING'",utcNow(),utcNow(),frozen.build().run());
jdbc.update("UPDATE ll_selfop_sync_task SET status='DEAD',updated_at=? WHERE id=? AND status='RUNNING'",utcNow(),frozen.task());
});
throw failure;
}
}
private Frozen freeze(String account,String warehouse,int days) {
versions.lockWarehouse(account,warehouse);
var active=jdbc.queryForList("SELECT t.id,r.id run_id,r.heartbeat_at_utc FROM ll_selfop_sync_task t JOIN ll_selfop_sync_run r ON r.task_id=t.id WHERE t.source_account=? AND JSON_UNQUOTE(JSON_EXTRACT(t.query_scope_json,'$.warehouse'))=? AND t.task_type=? AND t.status='RUNNING' AND r.status='BUILDING' FOR UPDATE",account,warehouse,TYPE);
if(!active.isEmpty()) {
var row=active.get(0);var beat=row.get("heartbeat_at_utc");
if(beat!=null&&InventoryFlowFacts.instant(beat).isAfter(Instant.now().minusSeconds(300)))return null;
jdbc.update("UPDATE ll_selfop_inventory_day_version SET status='FAILED' WHERE build_run_id=? AND status IN ('BUILDING','READY')",row.get("run_id"));
jdbc.update("UPDATE ll_selfop_sync_run SET status='ABORTED',error_code='HISTORY_BUILD_INTERRUPTED',finished_at_utc=? WHERE id=?",utcNow(),row.get("run_id"));
jdbc.update("UPDATE ll_selfop_sync_task SET status='DEAD',updated_at=? WHERE id=?",utcNow(),row.get("id"));
}
// A quarantined identity cannot silently move to a different customer/SKU/quality.
if(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_flow WHERE source_account=? AND warehouse_code=? AND validation_reason='FLOW_IDENTITY_CONFLICT'",Long.class,account,warehouse)>0)return null;
var anchor=anchor(account,warehouse,0);var reference=anchor(account,warehouse,1);
if(anchor==null||reference==null)return null;
LocalDate until=anchor.from().atOffset(ZoneOffset.ofHours(8)).toLocalDate();
Instant retainedFrom=until.minusDays(366).atStartOfDay().toInstant(ZoneOffset.ofHours(8));
Instant coverageFrom=reference.from().isBefore(retainedFrom)?reference.from():retainedFrom;
var coverageRows=jdbc.queryForList("SELECT r.id,r.covered_from_utc,r.covered_until_utc FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE t.source_account=? AND t.task_type=? AND r.status='FACTS_APPLIED' AND r.coverage_status='COMPLETE' AND JSON_UNQUOTE(JSON_EXTRACT(t.query_scope_json,'$.warehouse'))=? AND r.covered_until_utc>? ORDER BY r.covered_from_utc",account,InventoryFlowSlice.TYPE,warehouse,utc(coverageFrom));
var coverage=union(coverageRows.stream().map(r->new Interval(InventoryFlowFacts.instant(r.get("covered_from_utc")),InventoryFlowFacts.instant(r.get("covered_until_utc")))).toList());
var coverageRuns=coverageRows.stream().map(r->((Number)r.get("id")).longValue()).toList();
LocalDate from=until;
while(from.isAfter(until.minusDays(days))&&covered(coverage,from.minusDays(1).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),anchor.until().plusNanos(1)))from=from.minusDays(1);
if(from.equals(until))return null;
var heads=versions.heads(account,warehouse,until.minusDays(366),until);
// Corrections rebuild every retained date, or remain invalid until its coverage returns.
if(!heads.isEmpty()&&heads.keySet().stream().min(LocalDate::compareTo).orElseThrow().isBefore(from)) {
LocalDate earliest=heads.keySet().iterator().next();
if(!covered(coverage,earliest.atStartOfDay().toInstant(ZoneOffset.ofHours(8)),anchor.until().plusNanos(1)))return null;
from=earliest;
}
if(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE t.source_account=? AND t.task_type=? AND r.status='VALIDATED' AND JSON_UNQUOTE(JSON_EXTRACT(t.query_scope_json,'$.warehouse'))=? AND t.query_until_utc>? AND t.query_from_utc<?",Long.class,account,InventoryFlowSlice.TYPE,warehouse,from.atStartOfDay().minusHours(8),utc(anchor.until()))>0)return null;
var sales=jdbc.queryForList("SELECT published_run_id FROM ll_selfop_sync_scope WHERE source_account=? AND warehouse_code=? AND data_family='OUTBOUND'",account,warehouse);
Long salesRun=sales.isEmpty()||sales.get(0).get("published_run_id")==null?null:((Number)sales.get(0).get("published_run_id")).longValue();
String inputHash=versions.factHash(account,warehouse);
String signature=hash(Arrays.asList(inputHash,anchor,reference,from,until,salesRun,coverage));
if(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=? AND task_type=? AND request_hash=? AND status='SUCCEEDED'",Long.class,account,TYPE,signature)>0)return null;
// Failed identical inputs get bounded attempts, instead of an infinite hot loop.
if(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=? AND task_type=? AND request_hash=?",Long.class,account,TYPE,signature)>=3)return null;
long taskId=IdWorker.getId(),runId=IdWorker.getId(),group=IdWorker.getId();var now=utcNow();
String evidence=json(Map.of("warehouse",warehouse,"anchor",anchor,"reference",reference,"coverageRuns",coverageRuns,"inputStagesRun",runId,"rule",RULE));
var task=new SelfopSyncTask();task.setId(taskId);task.setTaskNo("LXRESTORE-"+UUID.randomUUID().toString().replace("-",""));
task.setSourceAccount(account);task.setDiscoveryScopeId(jdbc.queryForObject("SELECT id FROM ll_selfop_outbound_discovery_scope WHERE source_account=?",Long.class,account));task.setExecutionPurpose("BUSINESS");
task.setTaskRole("ACCOUNT_DISCOVERY");task.setTaskType(TYPE);task.setRequestKey("history-build:"+UUID.randomUUID());task.setRequestHash(signature);
task.setScopeHash(anchor.scopeHash());task.setQueryScopeJson(evidence);task.setStatus("RUNNING");task.setApplyState("NOT_APPLICABLE");task.setAttemptCount(1);task.setCreatedAt(now);task.setUpdatedAt(now);tasks.insert(task);
var run=new SelfopSyncRun();run.setId(runId);run.setTaskId(taskId);run.setSourceAccount(account);
run.setExecutionPurpose("BUSINESS");run.setRunRole("ACCOUNT_DISCOVERY");run.setScopeHash(anchor.scopeHash());run.setScopeSnapshotJson(evidence);run.setFenceToken(0L);run.setAttemptNo(1);
run.setBasePublishedRunId(anchor.id());run.setReferenceSalesRunId(salesRun);run.setStatus("BUILDING");run.setLastPhase("BUILDING");run.setCoverageStatus("COMPLETE");run.setPageCount(0);run.setUniqueObjectCount(0L);
run.setHeartbeatAtUtc(now);run.setCreatedAt(now);run.setUpdatedAt(now);runs.insert(run);
var build=new InventoryHistoryVersions.Build(account,warehouse,anchor.scopeHash(),runId,group,anchor.id(),salesRun,inputHash,evidence,Map.copyOf(heads));
var candidates=new ArrayList<InventoryHistoryVersions.Candidate>();for(LocalDate day=from;day.isBefore(until);day=day.plusDays(1))candidates.add(versions.create(build,day));
jdbc.update("UPDATE ll_selfop_sync_run SET progress_snapshot_json=? WHERE id=?",json(Map.of("candidates",candidates,"expectedHeads",heads,"inputHash",inputHash)),runId);
return new Frozen(build,List.copyOf(candidates),taskId,anchor,reference,from,until,coverage,coverageRuns);
}
private List<Sku> candidates(Frozen f,String afterOwner,String afterSku) {
return jdbc.query("SELECT customer_code,sku_code FROM (SELECT customer_code,sku_code FROM ll_selfop_inventory_snapshot WHERE snapshot_run_id=? UNION SELECT customer_code,sku_code FROM ll_selfop_sku_daily_sales WHERE sales_run_id=? AND business_date>=? AND business_date<? UNION SELECT customer_code,sku_code FROM ll_selfop_inventory_flow WHERE source_account=? AND warehouse_code=? UNION SELECT d.customer_code,d.sku_code FROM ll_selfop_inventory_daily d JOIN ll_selfop_inventory_day_version v ON v.id=d.day_version_id WHERE v.source_account=? AND v.warehouse_code=? AND v.head_slot=1) c WHERE customer_code>? OR (customer_code=? AND sku_code>?) ORDER BY customer_code,sku_code LIMIT 100",
(rs,n)->new Sku(rs.getString(1),rs.getString(2)),f.anchor().id(),f.build().sales(),f.from(),f.until(),f.build().account(),f.build().warehouse(),f.build().account(),f.build().warehouse(),afterOwner,afterOwner,afterSku);
}
private List<Anchor> anchors(StockRun run,Sku sku) {
return jdbc.query("SELECT s.product_quality,s.physical_quantity,s.quality_status,s.quality_reason,p.quantity_unit FROM ll_selfop_inventory_snapshot s LEFT JOIN ll_selfop_sku_profile p ON p.profile_run_id=s.snapshot_run_id AND p.customer_code=s.customer_code AND p.sku_code=s.sku_code WHERE s.snapshot_run_id=? AND s.customer_code=? AND s.sku_code=?",
(rs,n)->new Anchor(new Key(sku,rs.getInt(1)),("VALID".equals(rs.getString(3))||Set.of("FIELD_MISSING","NEGATIVE_QUANTITY").contains(Objects.toString(rs.getString(4),"")))?rs.getBigDecimal(2):null,
rs.getString(5),run.from(),run.until(),run.id()),run.id(),sku.owner(),sku.sku());
}
private List<Anchor> referenceAnchors(Frozen f,Sku sku){return anchors(f.reference(),sku);}
private Input input(Frozen f,Sku sku) {
Instant dayStart=f.from().atStartOfDay().toInstant(ZoneOffset.ofHours(8));
Instant referenceStart=f.reference().from().truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
Instant evidenceFrom=referenceStart.isBefore(dayStart)?referenceStart:dayStart;
var flows=jdbc.query("SELECT source_flow_id,product_quality,cell_no,inner_batch_no,bill_type,delta_quantity,book_qty,close_quantity,event_at_utc,validation_reason FROM ll_selfop_inventory_flow WHERE source_account=? AND warehouse_code=? AND customer_code=? AND sku_code=? AND (event_at_utc IS NULL OR (event_at_utc>=? AND event_at_utc<=?)) ORDER BY event_at_utc,source_flow_id",
(rs,n)->new Flow(rs.getString(1),new Key(sku,rs.getInt(2)),rs.getString(3),rs.getString(4),rs.getString(5),rs.getString(10)==null?rs.getBigDecimal(6):null,rs.getBigDecimal(7),rs.getBigDecimal(8),rs.getTimestamp(9)==null?null:rs.getTimestamp(9).toLocalDateTime().toInstant(ZoneOffset.UTC)),
f.build().account(),f.build().warehouse(),sku.owner(),sku.sku(),utc(evidenceFrom),utc(f.anchor().until()));
return new Input(f.from(),f.until(),Set.of(sku),flows,anchors(f.anchor(),sku),f.coverage());
}
private Object flowEvidence(Frozen f,Sku sku) {
return Map.of("sku",sku,"anchor",f.anchor(),"reference",f.reference(),"flowStages",jdbc.queryForList("SELECT source_flow_id,revision_no,stage_id,validation_reason FROM ll_selfop_inventory_flow WHERE source_account=? AND warehouse_code=? AND customer_code=? AND sku_code=? ORDER BY source_flow_id",f.build().account(),f.build().warehouse(),sku.owner(),sku.sku()));
}
static String crosscheck(List<Anchor> current,List<Anchor> prior,List<Flow> flows,List<Interval> coverage) {
if(current.isEmpty()||prior.isEmpty())return "INDEPENDENT_ANCHOR_MISSING";
if(!current.stream().map(Anchor::key).collect(java.util.stream.Collectors.toSet()).equals(prior.stream().map(Anchor::key).collect(java.util.stream.Collectors.toSet())))return "INDEPENDENT_ANCHOR_QUALITY_MISSING";
for(var a:current) {
var p=prior.stream().filter(v->v.key().equals(a.key())).findFirst().orElseThrow();
if(p.quantity()==null||a.quantity()==null||!"PCS".equals(p.unit())||!"PCS".equals(a.unit()))return "INDEPENDENT_ANCHOR_INVALID";
if(!p.observedUntil().isBefore(a.observedFrom())||!covered(coverage,p.observedFrom(),a.observedUntil().plusNanos(1)))return "INDEPENDENT_ANCHOR_COVERAGE_MISSING";
BigDecimal expected=p.quantity();
for(var flow:flows)if(flow.key().equals(a.key())) {
if(flow.at()==null||flow.delta()==null)return "INDEPENDENT_ANCHOR_FLOW_UNKNOWN";
if((!flow.at().isBefore(p.observedFrom().truncatedTo(java.time.temporal.ChronoUnit.SECONDS))&&!flow.at().isAfter(p.observedUntil()))
||(!flow.at().isBefore(a.observedFrom().truncatedTo(java.time.temporal.ChronoUnit.SECONDS))&&!flow.at().isAfter(a.observedUntil())))return "ANCHOR_TIME_AMBIGUOUS";
if(flow.at().isAfter(p.observedUntil())&&flow.at().isBefore(a.observedFrom()))expected=expected.add(flow.delta());
}
if(expected.compareTo(a.quantity())!=0)return "INDEPENDENT_ANCHOR_MISMATCH";
}
return null;
}
private void saveInput(Frozen f,Sku sku,Object evidence,String digest) {
var stage=new SelfopSyncStage();stage.setId(IdWorker.getId());stage.setRunId(f.build().run());stage.setStageBatchId(f.build().run());stage.setLastFetchRunId(f.build().run());stage.setWarehouseCode(f.build().warehouse());
stage.setObjectType("HISTORY_INPUT");stage.setObjectKeyHash(hash(sku));stage.setContentHash(digest);stage.setPayloadJson(json(evidence));stage.setFirstPageNo(1);stage.setValidationStatus("VALID");stage.setObservedAtUtc(utcNow());stage.setCreatedAt(utcNow());stage.setUpdatedAt(utcNow());stages.insert(stage);
}
private void requireBuilding(Frozen f){require("BUILDING".equals(jdbc.queryForObject("SELECT status FROM ll_selfop_sync_run WHERE id=? FOR UPDATE",String.class,f.build().run())),"HISTORY_BUILD_LOST");}
private void heartbeat(Frozen f){jdbc.update("UPDATE ll_selfop_sync_run SET heartbeat_at_utc=?,updated_at=? WHERE id=? AND status='BUILDING'",utcNow(),utcNow(),f.build().run());}
private static List<Interval> union(List<Interval> input) {
var result=new ArrayList<Interval>();
for(var next:input.stream().sorted(Comparator.comparing(Interval::from)).toList()) {
if(result.isEmpty()||result.get(result.size()-1).until().isBefore(next.from()))result.add(next);
else {var old=result.remove(result.size()-1);result.add(new Interval(old.from(),old.until().isAfter(next.until())?old.until():next.until()));}
}
return List.copyOf(result);
}
private static LocalDateTime utc(Instant instant){return LocalDateTime.ofInstant(instant,ZoneOffset.UTC);}
}
@@ -0,0 +1,141 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
/** Pure reconstruction over frozen, complete source intervals. Never invents absent stock. */
public final class InventoryHistoryCalculator {
public static final String RULE = "FLOW_EOD_V1";
private static final Set<String> VERIFIED_TYPES = Set.of("DO", "PA", "IMO", "IAT", "RI");
private static final ZoneOffset BUSINESS = ZoneOffset.ofHours(8);
private InventoryHistoryCalculator() { }
public record Sku(String owner, String sku) { }
public record Key(Sku sku, int quality) { }
public record Interval(Instant from, Instant until) {
public Interval { if (from == null || until == null || !from.isBefore(until)) throw new IllegalArgumentException("INVALID_COVERAGE"); }
}
public record Flow(String id, Key key, String cell, String batch, String type,
BigDecimal delta, BigDecimal bookQuantity, BigDecimal closing, Instant at) { }
public record Anchor(Key key, BigDecimal quantity, String unit, Instant observedFrom, Instant observedUntil, long runId) { }
public record Daily(LocalDate date, Key key, BigDecimal quantity, String status, String reason, long anchorRun) { }
public record Input(LocalDate from, LocalDate until, Set<Sku> candidates, List<Flow> flows,
List<Anchor> anchors, List<Interval> coverage) { }
public static List<Daily> reconstruct(Input input) {
Objects.requireNonNull(input);
if (input.from()==null || input.until()==null || !input.from().isBefore(input.until())
|| input.until().isAfter(input.from().plusDays(366))) throw new IllegalArgumentException("INVALID_HISTORY_WINDOW");
var byKey = new HashMap<Key,List<Flow>>();
var anchors = new HashMap<Key,Anchor>();
var reasons = new HashMap<Key,String>();
var ids = new HashMap<String,Flow>();
var keys = new HashSet<Key>();
for (Anchor a : input.anchors()) {
requireKey(a.key()); keys.add(a.key());
if (anchors.putIfAbsent(a.key(),a)!=null) reasons.put(a.key(),"ANCHOR_DUPLICATE");
}
for (Flow f : input.flows()) {
requireKey(f.key()); keys.add(f.key());
if (f.id()==null || f.id().isBlank() || f.id().length()>64) { reasons.put(f.key(),"FLOW_ID_INVALID"); continue; }
Flow old=ids.putIfAbsent(f.id(),f);
if (old!=null) {
if (!old.equals(f)) { reasons.put(old.key(),"FLOW_ID_CONFLICT"); reasons.put(f.key(),"FLOW_ID_CONFLICT"); }
continue;
}
byKey.computeIfAbsent(f.key(),k->new ArrayList<>()).add(f);
}
var observedSkus=new HashSet<Sku>();for(var key:keys)observedSkus.add(key.sku());
for (Sku sku : input.candidates()) if (!observedSkus.contains(sku)) keys.add(new Key(sku,0));
var result = new ArrayList<Daily>();
var ordered = keys.stream().sorted(Comparator.comparing((Key k)->k.sku().owner()).thenComparing(k->k.sku().sku()).thenComparingInt(Key::quality)).toList();
for (Key key : ordered) {
Anchor a=anchors.get(key);
List<Flow> rows=byKey.getOrDefault(key,List.of());
String failure=reasons.get(key);
if (failure==null) failure=validateAnchor(a);
if (failure==null && key.quality()!=0 && key.quality()!=1) failure="UNKNOWN_QUALITY";
if (failure==null) failure=validateRows(rows,a);
for (LocalDate day=input.from();day.isBefore(input.until());day=day.plusDays(1)) {
Instant boundary=day.plusDays(1).atStartOfDay().toInstant(BUSINESS);
String reason=failure;
if (reason==null && a.observedFrom().isBefore(boundary)) reason="ANCHOR_BEFORE_DAY_END";
if (reason==null && !covered(input.coverage(),boundary,a.observedUntil().plusNanos(1))) reason="FLOW_COVERAGE_GAP";
BigDecimal quantity=null;
if (reason==null) {
quantity=a.quantity();
for (Flow f:rows) if (!f.at().isBefore(boundary) && f.at().isBefore(a.observedFrom())) quantity=quantity.subtract(f.delta());
if (quantity.signum()<0) { quantity=null; reason="NEGATIVE_RECONSTRUCTED_STOCK"; }
}
result.add(new Daily(day,key,quantity,reason==null?"VALID":"UNKNOWN",reason,a==null?0:a.runId()));
}
}
return List.copyOf(result);
}
private static String validateAnchor(Anchor a) {
if (a==null) return "ANCHOR_MISSING";
if (!"PCS".equals(a.unit())) return "UNIT_UNKNOWN";
if (!quantity(a.quantity()) || a.quantity().signum()<0) return "ANCHOR_QUANTITY_INVALID";
if (a.runId()<=0 || a.observedFrom()==null || a.observedUntil()==null || a.observedUntil().isBefore(a.observedFrom())) return "ANCHOR_INTERVAL_INVALID";
return null;
}
private static String validateRows(List<Flow> rows, Anchor anchor) {
var chains=new HashMap<List<String>,List<Flow>>();
for (Flow f:rows) {
if (f.at()==null || !quantity(f.delta()) || !quantity(f.bookQuantity()) || !quantity(f.closing())) return "FLOW_QUANTITY_OR_TIME_INVALID";
if (f.type()==null || !VERIFIED_TYPES.contains(f.type())) return "FLOW_TYPE_UNVERIFIED";
if (f.delta().compareTo(f.bookQuantity())!=0) return "FLOW_QUANTITY_CONFLICT";
if (f.closing().signum()<0 || f.closing().subtract(f.delta()).signum()<0) return "NEGATIVE_CHAIN_STOCK";
if (f.cell()==null || f.cell().isBlank() || f.batch()==null || f.batch().isBlank()) return "CHAIN_IDENTITY_MISSING";
// Source events have second resolution; an event in the anchor's first second is ambiguous.
if (!f.at().isBefore(anchor.observedFrom().truncatedTo(java.time.temporal.ChronoUnit.SECONDS))
&& !f.at().isAfter(anchor.observedUntil())) return "ANCHOR_TIME_AMBIGUOUS";
chains.computeIfAbsent(List.of(f.cell(),f.batch()),k->new ArrayList<>()).add(f);
}
for (var chain:chains.values()) {
var seconds=new TreeMap<Instant,List<Flow>>();
for(Flow f:chain) seconds.computeIfAbsent(f.at(),k->new ArrayList<>()).add(f);
BigDecimal prior=null;
for(var group:seconds.values()) {
if(group.size()>8) return "CHAIN_ORDER_AMBIGUOUS";
var endings=new ArrayList<BigDecimal>();
paths(group,new boolean[group.size()],prior,0,endings);
if(endings.isEmpty()) return "CHAIN_DISCONTINUITY";
if(endings.size()!=1) return "CHAIN_ORDER_AMBIGUOUS";
prior=endings.get(0);
}
}
var deltas=new TreeMap<Instant,BigDecimal>(Comparator.reverseOrder());
for(var row:rows)if(row.at().isBefore(anchor.observedFrom()))deltas.merge(row.at(),row.delta(),BigDecimal::add);
BigDecimal balance=anchor.quantity();
for(var delta:deltas.values()) {
balance=balance.subtract(delta);
if(balance.signum()<0)return "NEGATIVE_RECONSTRUCTED_STOCK";
}
return null;
}
private static void paths(List<Flow> group,boolean[] used,BigDecimal balance,int depth,List<BigDecimal> endings) {
if(endings.size()>1) return;
if(depth==group.size()) { endings.add(balance); return; }
for(int i=0;i<group.size();i++) if(!used[i]) {
Flow f=group.get(i);
if(balance!=null && balance.add(f.delta()).compareTo(f.closing())!=0) continue;
used[i]=true;paths(group,used,f.closing(),depth+1,endings);used[i]=false;
}
}
public static boolean covered(List<Interval> intervals,Instant from,Instant until) {
Instant cursor=from;
for(Interval i:intervals.stream().sorted(Comparator.comparing(Interval::from)).toList()) {
if(i.until().isBefore(cursor)) continue;
if(i.from().isAfter(cursor)) return false;
if(i.until().isAfter(cursor)) cursor=i.until();
if(!cursor.isBefore(until)) return true;
}
return false;
}
private static boolean quantity(BigDecimal value) { return value!=null && value.scale()<=6 && value.precision()-value.scale()<=14; }
private static void requireKey(Key k) {
if(k==null || k.sku()==null || k.sku().owner()==null || k.sku().owner().isBlank() || k.sku().sku()==null || k.sku().sku().isBlank())
throw new IllegalArgumentException("HISTORY_IDENTITY_INVALID");
}
}
@@ -0,0 +1,100 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.support.TransactionTemplate;
import java.sql.Timestamp;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.history.InventoryHistoryCalculator.*;
/** Historical day heads have their own publication protocol, separate from raw stock runs. */
@Repository
public class InventoryHistoryVersions {
private final JdbcTemplate jdbc;
private final TransactionTemplate tx;
public InventoryHistoryVersions(JdbcTemplate jdbc,TransactionTemplate tx) { this.jdbc=jdbc;this.tx=tx; }
public record Build(String account,String warehouse,String scopeHash,long run,long group,long anchor,
Long sales,String inputHash,String evidence,Map<LocalDate,Long> expectedHeads) {
public Build { expectedHeads=Map.copyOf(expectedHeads); }
}
public record Candidate(long id,LocalDate date) { }
public void lockWarehouse(String account,String warehouse) {
var rows=jdbc.queryForList("SELECT id FROM ll_selfop_sync_scope WHERE source_account=? AND warehouse_code=? AND data_family='INVENTORY' FOR UPDATE",account,warehouse);
if(rows.size()!=1) throw new IllegalStateException("HISTORY_SCOPE_MISSING");
}
/** Monotone revisions and no deletion make this fingerprint stable under the warehouse lock. */
public String factHash(String account,String warehouse) {
return SelfopSupport.hash(jdbc.queryForMap("SELECT COUNT(*) n,COALESCE(SUM(revision_no),0) revisions,COALESCE(MAX(id),0) newest FROM ll_selfop_inventory_flow WHERE source_account=? AND warehouse_code=?",account,warehouse));
}
public Map<LocalDate,Long> heads(String account,String warehouse,LocalDate from,LocalDate until) {
var result=new TreeMap<LocalDate,Long>();
jdbc.query("SELECT business_date,id FROM ll_selfop_inventory_day_version WHERE source_account=? AND warehouse_code=? AND head_slot=1 AND business_date>=? AND business_date<? ORDER BY business_date",
rs->{result.put(rs.getDate(1).toLocalDate(),rs.getLong(2));},account,warehouse,from,until);
return result;
}
public Candidate create(Build build,LocalDate date) {
long id=IdWorker.getId();
jdbc.update("INSERT INTO ll_selfop_inventory_day_version (id,source_account,warehouse_code,business_date,scope_hash,rule_version,build_run_id,publication_group_id,sales_run_id,anchor_run_id,input_hash,evidence_json,status,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,'BUILDING',?)",
id,build.account(),build.warehouse(),date,build.scopeHash(),RULE,build.run(),build.group(),build.sales(),build.anchor(),build.inputHash(),build.evidence(),now());
return new Candidate(id,date);
}
/** Bounded insert transaction; a failed batch never changes visible day heads. */
public void append(Candidate candidate,List<Daily> rows,String evidenceHash) {
if(rows.size()>500 || rows.stream().anyMatch(r->!candidate.date().equals(r.date()))) throw new IllegalArgumentException("HISTORY_BATCH_INVALID");
tx.executeWithoutResult(status->{
String state=jdbc.queryForObject("SELECT status FROM ll_selfop_inventory_day_version WHERE id=? FOR UPDATE",String.class,candidate.id());
if(!"BUILDING".equals(state)) throw new IllegalStateException("HISTORY_CANDIDATE_IMMUTABLE");
jdbc.batchUpdate("INSERT INTO ll_selfop_inventory_daily (day_version_id,customer_code,sku_code,product_quality,physical_quantity,quality_status,quality_reason,anchor_reference,evidence_hash,source) VALUES (?,?,?,?,?,?,?,?,?,'FLOW_RECONSTRUCTED')",
rows,500,(p,r)->{p.setLong(1,candidate.id());p.setString(2,r.key().sku().owner());p.setString(3,r.key().sku().sku());p.setInt(4,r.key().quality());
p.setBigDecimal(5,r.quantity());p.setString(6,r.status());p.setString(7,r.reason());
if(r.anchorRun()==0)p.setNull(8,java.sql.Types.BIGINT);else p.setLong(8,r.anchorRun());p.setString(9,evidenceHash);});
});
}
public void ready(Candidate candidate,long expectedSkuCount) {
tx.executeWithoutResult(s->{
String state=jdbc.queryForObject("SELECT status FROM ll_selfop_inventory_day_version WHERE id=? FOR UPDATE",String.class,candidate.id());
if(!"BUILDING".equals(state)) throw new IllegalStateException("HISTORY_CANDIDATE_IMMUTABLE");
var totals=jdbc.queryForMap("SELECT COUNT(*) total,COALESCE(SUM(valid),0) valid FROM (SELECT customer_code,sku_code,MIN(CASE WHEN quality_status='VALID' THEN 1 ELSE 0 END) valid FROM ll_selfop_inventory_daily WHERE day_version_id=? GROUP BY customer_code,sku_code) x",candidate.id());
long count=((Number)totals.get("total")).longValue(),valid=((Number)totals.get("valid")).longValue();
if(count!=expectedSkuCount) throw new IllegalStateException("HISTORY_CANDIDATE_INCOMPLETE");
jdbc.update("UPDATE ll_selfop_inventory_day_version SET status='READY',valid_sku_count=?,unknown_sku_count=? WHERE id=?",valid,count-valid,candidate.id());
});
}
/** Must be called in the same warehouse-locked transaction that accepts a source revision. */
public void invalidate(String account,String warehouse,long run,String reason) {
jdbc.update("UPDATE ll_selfop_inventory_day_version SET invalidated_at=COALESCE(invalidated_at,?),invalidation_reason=COALESCE(invalidation_reason,?),rebuild_run_id=? WHERE source_account=? AND warehouse_code=? AND status IN ('PUBLISHED','SUPERSEDED')",
now(),reason,run,account,warehouse);
}
public void publish(Build build,List<Candidate> candidates) {
if(candidates.isEmpty() || candidates.size()>366 || candidates.stream().map(Candidate::date).distinct().count()!=candidates.size()) throw new IllegalArgumentException("HISTORY_GROUP_INVALID");
tx.executeWithoutResult(s->{
lockWarehouse(build.account(),build.warehouse());
var stored=jdbc.queryForList("SELECT id,business_date,status FROM ll_selfop_inventory_day_version WHERE publication_group_id=? AND build_run_id=? AND source_account=? AND warehouse_code=? ORDER BY business_date FOR UPDATE",build.group(),build.run(),build.account(),build.warehouse());
var expected=candidates.stream().map(Candidate::id).collect(java.util.stream.Collectors.toSet());
if(stored.size()!=candidates.size() || stored.stream().anyMatch(r->!expected.contains(((Number)r.get("id")).longValue()))) throw new IllegalStateException("HISTORY_GROUP_INCOMPLETE");
for(var row:stored) {
var candidate=candidates.stream().filter(c->c.id()==((Number)row.get("id")).longValue()).findFirst().orElseThrow();
if(!candidate.date().toString().equals(row.get("business_date").toString()))throw new IllegalStateException("HISTORY_GROUP_DATE_MISMATCH");
}
if(stored.stream().allMatch(r->"PUBLISHED".equals(r.get("status")))) return;
if(!build.scopeHash().equals(jdbc.queryForObject("SELECT scope_hash FROM ll_selfop_sync_scope WHERE source_account=? AND warehouse_code=? AND data_family='INVENTORY'",String.class,build.account(),build.warehouse())))throw new IllegalStateException("HISTORY_SCOPE_CHANGED");
if(!build.inputHash().equals(factHash(build.account(),build.warehouse()))) throw new IllegalStateException("HISTORY_INPUT_CHANGED");
if(stored.stream().anyMatch(r->!"READY".equals(r.get("status")))) throw new IllegalStateException("HISTORY_GROUP_NOT_READY");
for(var c:candidates) {
var heads=jdbc.queryForList("SELECT id FROM ll_selfop_inventory_day_version WHERE source_account=? AND warehouse_code=? AND business_date=? AND head_slot=1",Long.class,build.account(),build.warehouse(),c.date());
Long old=heads.isEmpty()?null:heads.get(0);
if(!Objects.equals(old,build.expectedHeads().get(c.date()))) throw new IllegalStateException("HISTORY_HEAD_CHANGED");
}
var at=now();
for(var c:candidates) {
jdbc.update("UPDATE ll_selfop_inventory_day_version SET status='SUPERSEDED',head_slot=NULL,superseded_at=? WHERE source_account=? AND warehouse_code=? AND business_date=? AND head_slot=1",at,build.account(),build.warehouse(),c.date());
int updated=jdbc.update("UPDATE ll_selfop_inventory_day_version SET status='PUBLISHED',head_slot=1,published_at=? WHERE id=? AND status='READY' AND invalidated_at IS NULL AND input_hash=?",at,c.id(),build.inputHash());
if(updated!=1) throw new IllegalStateException("HISTORY_CANDIDATE_CHANGED");
}
});
}
private static LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); }
}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.time.*;
import java.time.temporal.TemporalAdjusters;
import java.util.concurrent.*;
/** Two bounded lanes: provider pages never wait behind local reconstruction or the sales scheduler. */
@Component
public class InventoryHistoryWorker {
private static final Logger LOG=LoggerFactory.getLogger(InventoryHistoryWorker.class);
private final SelfopAnalyticsProperties config;private final InventoryFlowCollector collector;
private final InventoryFlowFacts facts;private final InventoryHistoryBuilder builder;private final JdbcTemplate jdbc;
private final ScheduledExecutorService http=Executors.newSingleThreadScheduledExecutor(r->{var t=new Thread(r,"selfop-history-pages");t.setDaemon(true);return t;});
private final ScheduledExecutorService local=Executors.newSingleThreadScheduledExecutor(r->{var t=new Thread(r,"selfop-history-local");t.setDaemon(true);return t;});
private Instant nextPlan=Instant.EPOCH;
private Instant nextBuild=Instant.EPOCH;
public InventoryHistoryWorker(SelfopAnalyticsProperties config,InventoryFlowCollector collector,InventoryFlowFacts facts,InventoryHistoryBuilder builder,JdbcTemplate jdbc){this.config=config;this.collector=collector;this.facts=facts;this.builder=builder;this.jdbc=jdbc;}
boolean enabled(){return config.isInventoryHistoryEnabled()&&SelfopCollectionPolicy.continuousBusiness(config)&&"PCS".equals(config.getQuantityUnit())&&"Asia/Shanghai".equals(config.getSourceQueryTimezone());}
@PostConstruct public void start(){http.scheduleWithFixedDelay(()->guard(this::page),10,1,TimeUnit.SECONDS);local.scheduleWithFixedDelay(()->guard(this::local),12000,100,TimeUnit.MILLISECONDS);}
@PreDestroy public void close(){http.shutdownNow();local.shutdownNow();}
private void guard(Runnable action){if(!enabled())return;try{action.run();}catch(RuntimeException e){LOG.warn("Historical inventory worker deferred ({})",e.getClass().getSimpleName());}}
void page(){
if(Instant.now().isAfter(nextPlan)){plan();nextPlan=Instant.now().plusSeconds(60);}
collector.page();
}
void local(){
facts.mergeNext(config.getSourceAccount());
if(Instant.now().isBefore(nextBuild))return;
nextBuild=Instant.now().plusSeconds(5);
for(String warehouse:config.getWarehouses().keySet())if(enabled())builder.build(config.getSourceAccount(),warehouse,config.getInventoryHistoryDays());
}
void plan(){
var now=LocalDateTime.now(ZoneOffset.ofHours(8));if(now.toLocalTime().isBefore(LocalTime.of(0,5)))return;
var ids=jdbc.queryForList("SELECT id FROM ll_selfop_outbound_discovery_scope WHERE source_account=?",Long.class,config.getSourceAccount());if(ids.size()!=1)return;
LocalDate today=now.toLocalDate();
for(String warehouse:config.getWarehouses().keySet()) {
var anchor=builder.anchor(config.getSourceAccount(),warehouse,0);if(anchor==null)continue;
long published=jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_day_version WHERE source_account=? AND warehouse_code=? AND head_slot=1 AND business_date>=? AND business_date<?",Long.class,config.getSourceAccount(),warehouse,today.minusDays(30),today);
int days=published<30?30:Math.max(30,Math.min(60,config.getInventoryHistoryDays()));
for(int age=1;age<=days;age++) {
LocalDate day=today.minusDays(age);
LocalDate round=age<=3?today:age<=30?today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)):LocalDate.of(2000,1,1);
collector.plan(ids.get(0),new InventoryFlowSlice(warehouse,day.atStartOfDay(),day.plusDays(1).atStartOfDay(),1000),round);
}
var end=LocalDateTime.ofInstant(anchor.until(),ZoneOffset.ofHours(8)).withNano(0).plusSeconds(1);
if(end.toLocalDate().equals(today)&&end.isAfter(today.atStartOfDay()))collector.plan(ids.get(0),new InventoryFlowSlice(warehouse,today.atStartOfDay(),end,1000),today);
}
}
}
@@ -15,11 +15,11 @@ public interface SelfopCoordinationMapper {
SelfopSyncTask lockTask(long id); SelfopSyncTask lockTask(long id);
@Select("SELECT * FROM ll_selfop_sync_run WHERE id=#{id} FOR UPDATE") @Select("SELECT * FROM ll_selfop_sync_run WHERE id=#{id} FOR UPDATE")
SelfopSyncRun lockRun(long id); SelfopSyncRun lockRun(long id);
@Select("SELECT * FROM ll_selfop_sync_task WHERE execution_purpose=#{purpose} AND task_type<>'INVENTORY_FLOW_PROBE' AND status IN ('READY','RETRY_WAIT','RUNNING') AND (execution_purpose='BUSINESS' OR (status='READY' AND validation_root_task_id=id)) AND (next_retry_at_utc IS NULL OR next_retry_at_utc<=#{now}) ORDER BY CASE WHEN task_type IN ('RECHECK','REBUILD') THEN 1 WHEN task_role='WAREHOUSE_SYNC' THEN 0 WHEN task_type='BACKFILL' THEN 3 ELSE 2 END,id LIMIT #{limit}") @Select("SELECT * FROM ll_selfop_sync_task WHERE execution_purpose=#{purpose} AND task_type NOT IN ('INVENTORY_FLOW_PROBE','INVENTORY_FLOW_HISTORY','INVENTORY_HISTORY_BUILD') AND status IN ('READY','RETRY_WAIT','RUNNING') AND (execution_purpose='BUSINESS' OR (status='READY' AND validation_root_task_id=id)) AND (next_retry_at_utc IS NULL OR next_retry_at_utc<=#{now}) ORDER BY CASE WHEN task_type IN ('RECHECK','REBUILD') THEN 1 WHEN task_role='WAREHOUSE_SYNC' THEN 0 WHEN task_type='BACKFILL' THEN 3 ELSE 2 END,id LIMIT #{limit}")
List<SelfopSyncTask> due(@Param("now") LocalDateTime now, @Param("limit") int limit, @Param("purpose") String purpose); List<SelfopSyncTask> due(@Param("now") LocalDateTime now, @Param("limit") int limit, @Param("purpose") String purpose);
// Started inventory work may resume across midnight; unstarted historical slots remain excluded. // Started inventory work may resume across midnight; unstarted historical slots remain excluded.
// Publication independently requires the planned day and observation day to match for daily samples. // Publication independently requires the planned day and observation day to match for daily samples.
@Select("SELECT t.* FROM ll_selfop_sync_task t JOIN ll_selfop_sync_task r ON r.id=t.business_root_task_id AND r.business_root_task_id=r.id AND r.source_account=t.source_account AND r.execution_purpose='BUSINESS' AND r.validation_root_task_id IS NULL AND r.status<>'DEAD' WHERE t.source_account=#{account} AND t.execution_purpose='BUSINESS' AND t.validation_root_task_id IS NULL AND (r.request_key LIKE 'manual:%' OR (r.requested_by='SELFOP_CONTINUOUS_PLANNER' AND r.request_key LIKE 'continuous:%')) AND t.status IN ('READY','RETRY_WAIT','RUNNING') AND (t.next_retry_at_utc IS NULL OR t.next_retry_at_utc<=#{now}) AND (r.request_key NOT LIKE 'continuous:inventory:%' OR DATE(DATE_ADD(r.planned_at_utc,INTERVAL 8 HOUR))=DATE(DATE_ADD(#{now},INTERVAL 8 HOUR)) OR (r.attempt_count>0 AND r.planned_at_utc<=#{now})) ORDER BY CASE WHEN r.task_role='ACCOUNT_DISCOVERY' AND r.query_until_utc>=DATE_SUB(DATE(DATE_ADD(#{now},INTERVAL 8 HOUR)),INTERVAL 8 HOUR) THEN 0 WHEN r.request_key LIKE 'continuous:inventory:%' THEN 1 WHEN t.task_role='WAREHOUSE_SYNC' THEN 2 WHEN t.task_type IN ('RECHECK','REBUILD') THEN 3 ELSE 4 END,t.query_until_utc DESC,t.id LIMIT #{limit}") @Select("SELECT t.* FROM ll_selfop_sync_task t JOIN ll_selfop_sync_task r ON r.id=t.business_root_task_id AND r.business_root_task_id=r.id AND r.source_account=t.source_account AND r.execution_purpose='BUSINESS' AND r.validation_root_task_id IS NULL AND r.status<>'DEAD' WHERE t.task_type NOT IN ('INVENTORY_FLOW_HISTORY','INVENTORY_HISTORY_BUILD') AND t.source_account=#{account} AND t.execution_purpose='BUSINESS' AND t.validation_root_task_id IS NULL AND (r.request_key LIKE 'manual:%' OR (r.requested_by='SELFOP_CONTINUOUS_PLANNER' AND r.request_key LIKE 'continuous:%')) AND t.status IN ('READY','RETRY_WAIT','RUNNING') AND (t.next_retry_at_utc IS NULL OR t.next_retry_at_utc<=#{now}) AND (r.request_key NOT LIKE 'continuous:inventory:%' OR DATE(DATE_ADD(r.planned_at_utc,INTERVAL 8 HOUR))=DATE(DATE_ADD(#{now},INTERVAL 8 HOUR)) OR (r.attempt_count>0 AND r.planned_at_utc<=#{now})) ORDER BY CASE WHEN r.task_role='ACCOUNT_DISCOVERY' AND r.query_until_utc>=DATE_SUB(DATE(DATE_ADD(#{now},INTERVAL 8 HOUR)),INTERVAL 8 HOUR) THEN 0 WHEN r.request_key LIKE 'continuous:inventory:%' THEN 1 WHEN t.task_role='WAREHOUSE_SYNC' THEN 2 WHEN t.task_type IN ('RECHECK','REBUILD') THEN 3 ELSE 4 END,t.query_until_utc DESC,t.id LIMIT #{limit}")
List<SelfopSyncTask> dueContinuous(@Param("now") LocalDateTime now,@Param("limit") int limit,@Param("account") String account); List<SelfopSyncTask> dueContinuous(@Param("now") LocalDateTime now,@Param("limit") int limit,@Param("account") String account);
@Select("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=#{account} AND apply_state IN ('APPLYING','BLOCKED') AND JSON_CONTAINS(affected_warehouse_codes_json,JSON_QUOTE(#{warehouse}))") @Select("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=#{account} AND apply_state IN ('APPLYING','BLOCKED') AND JSON_CONTAINS(affected_warehouse_codes_json,JSON_QUOTE(#{warehouse}))")
long barriers(@Param("account") String account, @Param("warehouse") String warehouse); long barriers(@Param("account") String account, @Param("warehouse") String warehouse);
@@ -15,6 +15,9 @@ public class SelfopAnalyticsProperties {
private String collectionMode = "OFF"; private String collectionMode = "OFF";
/** Explicit account-owner continuous BUSINESS planner/egress mode; off by default. */ /** Explicit account-owner continuous BUSINESS planner/egress mode; off by default. */
private boolean continuousBusinessCollectionEnabled = false; private boolean continuousBusinessCollectionEnabled = false;
/** Requires the history tables and both services from the same release. */
private boolean inventoryHistoryEnabled = false;
private int inventoryHistoryDays = 60;
private String environment = "test"; private String environment = "test";
private String exclusiveGrantId; private String exclusiveGrantId;
private LocalDateTime exclusiveGrantExpiresAtUtc; private LocalDateTime exclusiveGrantExpiresAtUtc;
@@ -37,6 +37,17 @@ public final class SelfopCollectionPolicy {
if(task==null||!"BUSINESS".equals(task.getExecutionPurpose())||task.getUpstreamTaskId()!=null if(task==null||!"BUSINESS".equals(task.getExecutionPurpose())||task.getUpstreamTaskId()!=null
||task.getValidationRootTaskId()!=null||!CONTINUOUS_PLANNER.equals(task.getRequestedBy())||task.getScopeHash()==null||!task.getScopeHash().matches("[a-f0-9]{64}"))return false; ||task.getValidationRootTaskId()!=null||!CONTINUOUS_PLANNER.equals(task.getRequestedBy())||task.getScopeHash()==null||!task.getScopeHash().matches("[a-f0-9]{64}"))return false;
String key=task.getRequestKey();if(key==null)return false; String key=task.getRequestKey();if(key==null)return false;
if(com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice.TYPE.equals(task.getTaskType())) {
try {
var slice=read(task.getQueryScopeJson(),com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice.class);
return "ACCOUNT_DISCOVERY".equals(task.getTaskRole())&&task.getDiscoveryScopeId()!=null&&task.getScopeId()==null
&&task.getWarehouseCode()==null&&task.getPlannedAtUtc()!=null&&task.getId().equals(task.getBusinessRootTaskId())
&&task.getScopeHash().equals(SelfopSupport.hash(slice))&&task.getRequestHash().equals(task.getScopeHash())
&&key.equals("continuous:history:"+task.getPlannedAtUtc().toLocalDate()+":"+task.getScopeHash())
&&java.time.LocalDateTime.ofInstant(slice.from(),java.time.ZoneOffset.UTC).equals(task.getQueryFromUtc())
&&java.time.LocalDateTime.ofInstant(slice.until(),java.time.ZoneOffset.UTC).equals(task.getQueryUntilUtc());
}catch(RuntimeException invalid){return false;}
}
if("MASTER_DATA".equals(task.getTaskType())) { if("MASTER_DATA".equals(task.getTaskType())) {
if(!"ACCOUNT_DISCOVERY".equals(task.getTaskRole())||task.getDiscoveryScopeId()==null||task.getScopeId()!=null if(!"ACCOUNT_DISCOVERY".equals(task.getTaskRole())||task.getDiscoveryScopeId()==null||task.getScopeId()!=null
||task.getWarehouseCode()!=null||task.getPlannedAtUtc()==null||task.getQueryFromUtc()!=null||task.getQueryUntilUtc()!=null)return false; ||task.getWarehouseCode()!=null||task.getPlannedAtUtc()==null||task.getQueryFromUtc()!=null||task.getQueryUntilUtc()!=null)return false;
@@ -9,11 +9,13 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverResponses.*; import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverResponses.*;
/** Tokens carry evidence, not authority: all versions and missing dates are reconstructed at asOf. */ /** Signed evidence fixes exact committed day versions; corrections explicitly revoke their validity. */
@Component @Component
public class InventoryTurnoverContext { public class InventoryTurnoverContext {
static final ZoneId ZONE=ZoneId.of("Asia/Shanghai"); static final ZoneId ZONE=ZoneId.of("Asia/Shanghai");
static final String RULE="DAILY_SCHEDULED_FIRST_V1"; static final String RULE="FLOW_EOD_V1";
// A service restart expires open pages/exports, which must request a fresh consistent snapshot.
private static final byte[] TOKEN_KEY=new java.security.SecureRandom().generateSeed(32);
private final AnalyticsReadMapper analytics; private final AnalyticsReadMapper analytics;
private final InventoryTurnoverReadMapper mapper; private final InventoryTurnoverReadMapper mapper;
private final ObjectMapper json; private final ObjectMapper json;
@@ -28,7 +30,7 @@ public class InventoryTurnoverContext {
} }
public record Evidence(String account,String warehouse,LocalDate end,int days,Instant asOf, public record Evidence(String account,String warehouse,LocalDate end,int days,Instant asOf,
Long salesRun,Long inventoryRun,String salesHash,String inventoryHash,boolean rebuild, Long salesRun,Long inventoryRun,String salesHash,String inventoryHash,boolean rebuild,
String samplesHash,LocalDate accumulationStartedOn) {} String samplesHash,LocalDate accumulationStartedOn,String rule,List<Sample> versions) {}
record Frozen(Context response,AnalyticsQueryService.Snapshot snapshot) {} record Frozen(Context response,AnalyticsQueryService.Snapshot snapshot) {}
Frozen resolve(InventoryTurnoverRequest q) { Frozen resolve(InventoryTurnoverRequest q) {
Instant now=clock.instant(); q.validate(now.atZone(ZONE).toLocalDate()); Instant now=clock.instant(); q.validate(now.atZone(ZONE).toLocalDate());
@@ -37,39 +39,59 @@ public class InventoryTurnoverContext {
if(asOf==null||asOf.isAfter(now)||!now.isBefore(asOf.plusSeconds(1800))) throw refresh(); if(asOf==null||asOf.isAfter(now)||!now.isBefore(asOf.plusSeconds(1800))) throw refresh();
LocalDate end=q.end(asOf.atZone(ZONE).toLocalDate()); LocalDate end=q.end(asOf.atZone(ZONE).toLocalDate());
if(supplied!=null&&(!Objects.equals(q.sourceAccount(),supplied.account())||!Objects.equals(q.warehouseCode(),supplied.warehouse()) if(supplied!=null&&(!Objects.equals(q.sourceAccount(),supplied.account())||!Objects.equals(q.warehouseCode(),supplied.warehouse())
||!end.equals(supplied.end())||q.days()!=supplied.days())) throw refresh(); ||!end.equals(supplied.end())||q.days()!=supplied.days()||!RULE.equals(supplied.rule())||supplied.versions()==null)) throw refresh();
var scopes=analytics.scopes(q.sourceAccount(),q.warehouseCode()); var scopes=analytics.scopes(q.sourceAccount(),q.warehouseCode());
var sales=version(scopes,"OUTBOUND",asOf); var stock=version(scopes,"INVENTORY",asOf); var sales=supplied==null?version(scopes,"OUTBOUND",asOf):fixed(supplied.salesRun());
var stock=supplied==null?version(scopes,"INVENTORY",asOf):fixed(supplied.inventoryRun());
var aq=new AnalyticsQuery(q.sourceAccount(),q.warehouseCode(),null,null,"CUSTOM",end.minusDays(Math.max(30,q.days())-1),end,"DAY",null,1,20, var aq=new AnalyticsQuery(q.sourceAccount(),q.warehouseCode(),null,null,"CUSTOM",end.minusDays(Math.max(30,q.days())-1),end,"DAY",null,1,20,
sales==null?0L:sales.id(),stock==null?0L:stock.id(),sales==null?null:sales.scopeHash(),stock==null?null:stock.scopeHash(),asOf); sales==null?0L:sales.id(),stock==null?0L:stock.id(),sales==null?null:sales.scopeHash(),stock==null?null:stock.scopeHash(),asOf);
// Staleness is evaluated at queryAsOf, so pagination does not age a fixed snapshot midway. // Staleness is evaluated at queryAsOf, so pagination does not age a fixed snapshot midway.
var snapshot=new AnalyticsQueryService(analytics,json,Clock.fixed(asOf,ZoneOffset.UTC),ownerDirectory).context(aq,true,false,false,null); var snapshot=new AnalyticsQueryService(analytics,json,Clock.fixed(asOf,ZoneOffset.UTC),ownerDirectory).context(aq,true,false,false,null);
String inventoryHash=scopes.stream().filter(s->"INVENTORY".equals(s.dataFamily())).map(AnalyticsReadRows.Scope::scopeHash).findFirst().orElse(null); String inventoryHash=scopes.stream().filter(s->"INVENTORY".equals(s.dataFamily())).map(AnalyticsReadRows.Scope::scopeHash).findFirst().orElse(null);
var mapping=new TreeMap<LocalDate,Long>(); var mapping=new TreeMap<LocalDate,Long>();
for(var sample:mapper.samples(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf),end.minusDays(Math.max(30,q.days())-1),end.plusDays(1))) { List<InventoryTurnoverReadMapper.DayVersion> selected;
if(sample.sampleDate()==null||sample.runId()==null||sample.runId()<=0||sample.sampleDate().isBefore(end.minusDays(Math.max(30,q.days())-1))||sample.sampleDate().isAfter(end) if(supplied==null)selected=mapper.samples(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf),end.minusDays(Math.max(30,q.days())-1),end.plusDays(1));
||mapping.put(sample.sampleDate(),sample.runId())!=null) throw refresh(); else {
if(supplied.versions().size()>366||supplied.versions().stream().anyMatch(v->v==null||v.dayVersionId()==null||v.dayVersionId()<=0))throw refresh();
selected=supplied.versions().isEmpty()?List.of():mapper.fixedSamples(q.sourceAccount(),q.warehouseCode(),inventoryHash,supplied.versions().stream().map(Sample::dayVersionId).toList());
if(!selected.stream().map(v->new Sample(v.sampleDate(),v.dayVersionId())).toList().equals(supplied.versions()))throw refresh();
}
for(var sample:selected) {
if(sample.sampleDate()==null||sample.dayVersionId()==null||sample.dayVersionId()<=0||sample.sampleDate().isBefore(end.minusDays(Math.max(30,q.days())-1))||sample.sampleDate().isAfter(end)
||mapping.put(sample.sampleDate(),sample.dayVersionId())!=null) throw refresh();
} }
List<Sample> samples=new ArrayList<>(); List<Sample> samples=new ArrayList<>();
for(LocalDate date=end.minusDays(Math.max(30,q.days())-1);!date.isAfter(end);date=date.plusDays(1)) samples.add(new Sample(date,mapping.get(date))); for(LocalDate date=end.minusDays(Math.max(30,q.days())-1);!date.isAfter(end);date=date.plusDays(1)) samples.add(new Sample(date,mapping.get(date)));
var began=mapper.accumulationStartedOn(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf)); var began=supplied==null?mapper.accumulationStartedOn(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf)):supplied.accumulationStartedOn();
var c=snapshot.context(); var c=snapshot.context();
var evidence=new Evidence(q.sourceAccount(),q.warehouseCode(),end,q.days(),asOf,c.salesRunId(),c.inventoryRunId(), var evidence=new Evidence(q.sourceAccount(),q.warehouseCode(),end,q.days(),asOf,c.salesRunId(),c.inventoryRunId(),
c.salesScopeHash(),c.inventoryScopeHash(),c.rebuildRequired(),samplesHash(samples),began); c.salesScopeHash(),c.inventoryScopeHash(),c.rebuildRequired(),samplesHash(samples),began,RULE,samples.stream().filter(v->v.dayVersionId()!=null).toList());
if(supplied!=null&&!supplied.equals(evidence)) throw refresh(); if(supplied!=null&&!supplied.equals(evidence)) throw refresh();
return new Frozen(new Context(encode(evidence),q.sourceAccount(),q.warehouseCode(),end,q.days(),asOf,c.queryExpiresAt(),c, return new Frozen(new Context(encode(evidence),q.sourceAccount(),q.warehouseCode(),end,q.days(),asOf,c.queryExpiresAt(),c,
c.inventoryObservedFrom(),c.inventoryObservedUntil(),RULE,List.copyOf(samples),samples.stream().filter(s->s.runId()==null).map(Sample::date).toList(),began),snapshot); c.inventoryObservedFrom(),c.inventoryObservedUntil(),RULE,List.copyOf(samples),samples.stream().filter(s->s.dayVersionId()==null).map(Sample::date).toList(),began),snapshot);
} }
private AnalyticsReadRows.Run fixed(Long id){if(id==null||id==0)return null;var run=analytics.publishedRun(id);if(run==null)throw refresh();return run;}
private AnalyticsReadRows.Run version(List<AnalyticsReadRows.Scope> scopes,String family,Instant asOf) { private AnalyticsReadRows.Run version(List<AnalyticsReadRows.Scope> scopes,String family,Instant asOf) {
var scope=scopes.stream().filter(s->family.equals(s.dataFamily())).findFirst().orElse(null); var scope=scopes.stream().filter(s->family.equals(s.dataFamily())).findFirst().orElse(null);
return scope==null?null:mapper.versionAt(scope.id(),scope.scopeHash(),utc(asOf)); return scope==null?null:mapper.versionAt(scope.id(),scope.scopeHash(),utc(asOf));
} }
private Evidence decode(String token) { private Evidence decode(String token) {
try {var value=json.readValue(Base64.getUrlDecoder().decode(token),Evidence.class);if(value==null)throw refresh();return value;} catch(Exception e){throw refresh();} try {
if(token.length()>16000)throw refresh();var parts=token.split("\\.",-1);if(parts.length!=2)throw refresh();
byte[] compressed=Base64.getUrlDecoder().decode(parts[0]);
if(!java.security.MessageDigest.isEqual(sign(compressed),Base64.getUrlDecoder().decode(parts[1])))throw refresh();
try(var gzip=new java.util.zip.GZIPInputStream(new java.io.ByteArrayInputStream(compressed))){
byte[] bytes=gzip.readNBytes(65537);if(bytes.length>65536)throw refresh();
var value=json.readValue(bytes,Evidence.class);if(value==null)throw refresh();return value;
}
} catch(Exception e){throw refresh();}
} }
private String encode(Evidence evidence) { private String encode(Evidence evidence) {
try{return Base64.getUrlEncoder().withoutPadding().encodeToString(json.writeValueAsBytes(evidence));}catch(Exception e){throw new IllegalStateException("Cannot encode turnover context",e);} try{var output=new java.io.ByteArrayOutputStream();try(var gzip=new java.util.zip.GZIPOutputStream(output)){gzip.write(json.writeValueAsBytes(evidence));}
byte[] data=output.toByteArray();var b64=Base64.getUrlEncoder().withoutPadding();return b64.encodeToString(data)+"."+b64.encodeToString(sign(data));
}catch(Exception e){throw new IllegalStateException("Cannot encode turnover context",e);}
} }
private static byte[] sign(byte[] bytes)throws java.security.GeneralSecurityException{var mac=javax.crypto.Mac.getInstance("HmacSHA256");mac.init(new javax.crypto.spec.SecretKeySpec(TOKEN_KEY,"HmacSHA256"));return mac.doFinal(bytes);}
private String samplesHash(List<Sample> samples) { private String samplesHash(List<Sample> samples) {
// Reconstruct the entire ordered calendar at asOf before comparing its digest. // Reconstruct the entire ordered calendar at asOf before comparing its digest.
// Keep the GET pagination token bounded even for a full year of samples. // Keep the GET pagination token bounded even for a full year of samples.
@@ -77,11 +77,16 @@ public final class InventoryTurnoverPolicy {
if(decimal(r,"frozen")!=null&&decimal(r,"frozen").signum()>0)attention.add("FROZEN_STOCK"); if(decimal(r,"frozen")!=null&&decimal(r,"frozen").signum()>0)attention.add("FROZEN_STOCK");
if(decimal(r,"defective")!=null&&decimal(r,"defective").signum()>0)attention.add("DEFECTIVE_STOCK"); if(decimal(r,"defective")!=null&&decimal(r,"defective").signum()>0)attention.add("DEFECTIVE_STOCK");
if(count(r,"unassessable")>0)attention.add("UNASSESSABLE"); if(count(r,"unassessable")>0)attention.add("UNASSESSABLE");
var sampleReasons=new LinkedHashSet<String>();
if(!"VALID".equals(sampleStatus)) {
sampleReasons.add(sampleStatus);String historyReasons=text(r,"history_reasons");
if(historyReasons!=null&&!historyReasons.isBlank())sampleReasons.addAll(Arrays.asList(historyReasons.split(",")));
}
String last=text(r,"last_outbound"); String last=text(r,"last_outbound");
return new Row(text(r,"customer_code"),text(r,"customer_name"),owner?null:text(r,"sku_code"),owner?null:text(r,"product_name"),physical,available, return new Row(text(r,"customer_code"),text(r,"customer_name"),owner?null:text(r,"sku_code"),owner?null:text(r,"product_name"),physical,available,
total(r,"frozen",owner),total(r,"defective",owner),total(r,"transit",owner),owner?decimal(r,"physical_skus"):physical.value()==null?null:physical.value().signum()>0?BigDecimal.ONE:BigDecimal.ZERO, total(r,"frozen",owner),total(r,"defective",owner),total(r,"transit",owner),owner?decimal(r,"physical_skus"):physical.value()==null?null:physical.value().signum()>0?BigDecimal.ONE:BigDecimal.ZERO,
windows,base.averageDailyOutboundQuantity(),coverage,coverageStatus,average,turnover,sampleStatus,sampleCount,q.days(),List.copyOf(missing), windows,base.averageDailyOutboundQuantity(),coverage,coverageStatus,average,turnover,sampleStatus,sampleCount,q.days(),List.copyOf(missing),
"VALID".equals(sampleStatus)?List.of():List.of(sampleStatus),last==null?null:LocalDate.parse(last.substring(0,10)),List.copyOf(attention), List.copyOf(sampleReasons),last==null?null:LocalDate.parse(last.substring(0,10)),List.copyOf(attention),
risk(r,"no_outbound",physical,owner,true),risk(r,"slow",physical,owner,q.coverageDaysThreshold()!=null),count(r,"unassessable"), risk(r,"no_outbound",physical,owner,true),risk(r,"slow",physical,owner,q.coverageDaysThreshold()!=null),count(r,"unassessable"),
owner?null:decimal(r,"unit_volume"),owner?null:text(r,"dimension_source"),volume(r,"physical"),volumeWindows(r,context.endDate().plusDays(1),q.windows())); owner?null:decimal(r,"unit_volume"),owner?null:text(r,"dimension_source"),volume(r,"physical"),volumeWindows(r,context.endDate().plusDays(1),q.windows()));
} }
@@ -8,29 +8,22 @@ import org.apache.ibatis.annotations.*;
@Mapper @Mapper
public interface InventoryTurnoverReadMapper { public interface InventoryTurnoverReadMapper {
record SampleRun(LocalDate sampleDate,Long runId) {} record DayVersion(LocalDate sampleDate,Long dayVersionId) {}
@Select("SELECT "+AnalyticsReadMapper.RUN_COLUMNS+" FROM ll_selfop_sync_run WHERE scope_id=#{scopeId} " @Select("SELECT "+AnalyticsReadMapper.RUN_COLUMNS+" FROM ll_selfop_sync_run WHERE scope_id=#{scopeId} "
+"AND scope_hash=#{scopeHash} AND status='PUBLISHED' AND run_role='WAREHOUSE_SYNC' AND execution_purpose='BUSINESS' " +"AND scope_hash=#{scopeHash} AND status='PUBLISHED' AND run_role='WAREHOUSE_SYNC' AND execution_purpose='BUSINESS' "
+"AND published_at_utc<=#{asOf} ORDER BY published_at_utc DESC,id DESC LIMIT 1") +"AND published_at_utc<=#{asOf} ORDER BY published_at_utc DESC,id DESC LIMIT 1")
AnalyticsReadRows.Run versionAt(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash,@Param("asOf") LocalDateTime asOf); AnalyticsReadRows.Run versionAt(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash,@Param("asOf") LocalDateTime asOf);
// The immutable first-daily marker is set only by publication. Recheck its original schedule evidence. String HISTORY_WHERE=" source_account=#{account} AND warehouse_code=#{warehouse} AND scope_hash=#{scopeHash,jdbcType=VARCHAR} "
String SAMPLE_WHERE=" r.source_account=#{account} AND r.warehouse_code=#{warehouse} AND r.scope_hash=#{scopeHash,jdbcType=VARCHAR} " +"AND rule_version='FLOW_EOD_V1' AND status IN ('PUBLISHED','SUPERSEDED') AND invalidated_at IS NULL ";
+"AND r.inventory_sample_rule_version='DAILY_SCHEDULED_FIRST_V1' AND r.status='PUBLISHED' " @Select("SELECT business_date sample_date,id day_version_id FROM ll_selfop_inventory_day_version WHERE "+HISTORY_WHERE
+"AND r.execution_purpose='BUSINESS' AND r.run_role='WAREHOUSE_SYNC' AND r.coverage_status='COMPLETE' " +"AND head_slot=1 AND published_at<=#{asOf} AND business_date>=#{from} AND business_date<#{until} ORDER BY business_date")
+"AND r.published_at_utc<=#{asOf} AND t.execution_purpose='BUSINESS' AND t.task_type='SCHEDULED' " List<DayVersion> samples(@Param("account") String account,@Param("warehouse") String warehouse,@Param("scopeHash") String scopeHash,
+"AND t.task_role='WAREHOUSE_SYNC' AND t.scope_hash=r.scope_hash "
+"AND t.source_account=r.source_account AND t.warehouse_code=r.warehouse_code AND r.page_count>=1 AND r.source_total_count>=0 "
+"AND r.source_total_count=r.unique_object_count AND r.fetch_started_at_utc<=r.fetch_finished_at_utc "
+"AND DATE(DATE_ADD(t.planned_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date "
+"AND DATE(DATE_ADD(r.fetch_started_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date "
+"AND DATE(DATE_ADD(r.fetch_finished_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date ";
@Select("SELECT r.inventory_daily_sample_date sample_date,r.id run_id FROM ll_selfop_sync_run r "
+"JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE "+SAMPLE_WHERE
+"AND r.inventory_daily_sample_date>=#{from} AND r.inventory_daily_sample_date<#{until} ORDER BY r.inventory_daily_sample_date")
List<SampleRun> samples(@Param("account") String account,@Param("warehouse") String warehouse,@Param("scopeHash") String scopeHash,
@Param("asOf") LocalDateTime asOf,@Param("from") LocalDate from,@Param("until") LocalDate until); @Param("asOf") LocalDateTime asOf,@Param("from") LocalDate from,@Param("until") LocalDate until);
@Select("SELECT MIN(r.inventory_daily_sample_date) FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE "+SAMPLE_WHERE) @Select("<script>SELECT business_date sample_date,id day_version_id FROM ll_selfop_inventory_day_version WHERE "+HISTORY_WHERE
+"AND id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY business_date</script>")
List<DayVersion> fixedSamples(@Param("account") String account,@Param("warehouse") String warehouse,@Param("scopeHash") String scopeHash,@Param("ids") List<Long> ids);
@Select("SELECT MIN(business_date) FROM ll_selfop_inventory_day_version WHERE "+HISTORY_WHERE+" AND head_slot=1 AND published_at<=#{asOf}")
LocalDate accumulationStartedOn(@Param("account") String account,@Param("warehouse") String warehouse,@Param("scopeHash") String scopeHash,@Param("asOf") LocalDateTime asOf); LocalDate accumulationStartedOn(@Param("account") String account,@Param("warehouse") String warehouse,@Param("scopeHash") String scopeHash,@Param("asOf") LocalDateTime asOf);
@SelectProvider(type=InventoryTurnoverSql.class,method="page") @SelectProvider(type=InventoryTurnoverSql.class,method="page")
@@ -23,7 +23,7 @@ public final class InventoryTurnoverResponses {
List<LocalDate> missingSampleDates,List<String> sampleReasonCodes,LocalDate lastObservedOutboundDate, List<LocalDate> missingSampleDates,List<String> sampleReasonCodes,LocalDate lastObservedOutboundDate,
List<String> attentionReasons,Risk noOutboundInWindow,Risk slowCoverage,int unassessableSkuCount, List<String> attentionReasons,Risk noOutboundInWindow,Risk slowCoverage,int unassessableSkuCount,
BigDecimal unitVolumeM3,String dimensionSource,Volume inventoryVolumeM3,List<VolumeWindow> outboundVolumesM3) {} BigDecimal unitVolumeM3,String dimensionSource,Volume inventoryVolumeM3,List<VolumeWindow> outboundVolumesM3) {}
public record Sample(LocalDate date,Long runId) {} public record Sample(LocalDate date,Long dayVersionId) {}
public record Context(String snapshotContext,String sourceAccount,String warehouseCode,LocalDate endDate, public record Context(String snapshotContext,String sourceAccount,String warehouseCode,LocalDate endDate,
int baseWindowDays,Instant queryAsOf,Instant queryExpiresAt,AnalyticsResponses.Context analyticsContext, int baseWindowDays,Instant queryAsOf,Instant queryExpiresAt,AnalyticsResponses.Context analyticsContext,
Instant inventoryObservedFrom,Instant inventoryObservedUntil,String sampleRuleVersion, Instant inventoryObservedFrom,Instant inventoryObservedUntil,String sampleRuleVersion,
@@ -6,7 +6,7 @@ import java.util.*;
/** Every value is bound. Only validated enums select SQL fragments; no source order/remote API reads. */ /** Every value is bound. Only validated enums select SQL fragments; no source order/remote API reads. */
public final class InventoryTurnoverSql { public final class InventoryTurnoverSql {
private InventoryTurnoverSql() {} private InventoryTurnoverSql() {}
public record Day(LocalDate date,LocalDateTime startUtc,int coverage,Long sampleRun) {} public record Day(LocalDate date,LocalDateTime startUtc,int coverage,Long historyVersionId) {}
public record Input(InventoryTurnoverRequest query,long salesRun,long inventoryRun,boolean stockFresh, public record Input(InventoryTurnoverRequest query,long salesRun,long inventoryRun,boolean stockFresh,
boolean salesKnown,List<Day> days,LocalDateTime queryAsOf) { boolean salesKnown,List<Day> days,LocalDateTime queryAsOf) {
public LocalDate until(){return days.get(days.size()-1).date().plusDays(1);} public LocalDate until(){return days.get(days.size()-1).date().plusDays(1);}
@@ -31,7 +31,7 @@ public final class InventoryTurnoverSql {
var coverage=SalesQueryCoverage.intervals(frozen.snapshot().sales()); var coverage=SalesQueryCoverage.intervals(frozen.snapshot().sales());
boolean trusted=frozen.snapshot().sourceKnown()&&(publishedHistory||!frozen.response().analyticsContext().rebuildRequired()); boolean trusted=frozen.snapshot().sourceKnown()&&(publishedHistory||!frozen.response().analyticsContext().rebuildRequired());
var days=frozen.response().dailySamples().stream().map(s->new Day(s.date(),s.date().atStartOfDay().minusHours(8), var days=frozen.response().dailySamples().stream().map(s->new Day(s.date(),s.date().atStartOfDay().minusHours(8),
!trusted?0:SalesQueryCoverage.complete(coverage,s.date(),s.date().plusDays(1))?1:SalesQueryCoverage.at(coverage,s.date())==null?0:2,s.runId())).toList(); !trusted?0:SalesQueryCoverage.complete(coverage,s.date(),s.date().plusDays(1))?1:SalesQueryCoverage.at(coverage,s.date())==null?0:2,s.dayVersionId())).toList();
return new Input(q,frozen.response().analyticsContext().salesRunId(),frozen.response().analyticsContext().inventoryRunId(), return new Input(q,frozen.response().analyticsContext().salesRunId(),frozen.response().analyticsContext().inventoryRunId(),
"VALID".equals(frozen.response().analyticsContext().inventoryStatus()),frozen.snapshot().sourceKnown(),days,LocalDateTime.ofInstant(frozen.response().queryAsOf(),ZoneOffset.UTC)); "VALID".equals(frozen.response().analyticsContext().inventoryStatus()),frozen.snapshot().sourceKnown(),days,LocalDateTime.ofInstant(frozen.response().queryAsOf(),ZoneOffset.UTC));
} }
@@ -49,15 +49,15 @@ public final class InventoryTurnoverSql {
private static String calendar(Input p) { private static String calendar(Input p) {
var parts=new ArrayList<String>(); var parts=new ArrayList<String>();
for(int i=0;i<p.days().size();i++) parts.add("SELECT CAST(#{p.days["+i+"].date} AS DATE) business_date,CAST(#{p.days["+i+"].startUtc} AS DATETIME) start_utc," for(int i=0;i<p.days().size();i++) parts.add("SELECT CAST(#{p.days["+i+"].date} AS DATE) business_date,CAST(#{p.days["+i+"].startUtc} AS DATETIME) start_utc,"
+"CAST(#{p.days["+i+"].coverage} AS SIGNED) coverage,CAST(#{p.days["+i+"].sampleRun,jdbcType=BIGINT} AS SIGNED) sample_run"); +"CAST(#{p.days["+i+"].coverage} AS SIGNED) coverage,CAST(#{p.days["+i+"].historyVersionId,jdbcType=BIGINT} AS SIGNED) sample_run");
return String.join(" UNION ALL ",parts); return String.join(" UNION ALL ",parts);
} }
private static String sampleRuns(Input p) { private static String historyVersions(Input p) {
// The frozen context already resolved these versions. Bind them directly instead // The frozen context already resolved these versions. Bind them directly instead
// of repeatedly expanding the 30-day calendar as a membership subquery. // of repeatedly expanding the 30-day calendar as a membership subquery.
var runs=new ArrayList<String>(); var runs=new ArrayList<String>();
for(int i=0;i<p.days().size();i++) if(p.days().get(i).sampleRun()!=null) for(int i=0;i<p.days().size();i++) if(p.days().get(i).historyVersionId()!=null)
runs.add("#{p.days["+i+"].sampleRun,jdbcType=BIGINT}"); runs.add("#{p.days["+i+"].historyVersionId,jdbcType=BIGINT}");
return runs.isEmpty()?"NULL":String.join(",",runs); return runs.isEmpty()?"NULL":String.join(",",runs);
} }
private static String salesDays(Input p) { private static String salesDays(Input p) {
@@ -69,14 +69,15 @@ public final class InventoryTurnoverSql {
+amount("transit_quantity")+" transit,"+amount("frozen_quantity")+" frozen,"+AnalyticsInventorySql.availableQuantity(valid("available_quantity"))+" available," +amount("transit_quantity")+" transit,"+amount("frozen_quantity")+" frozen,"+AnalyticsInventorySql.availableQuantity(valid("available_quantity"))+" available,"
+"CASE WHEN MIN(CASE WHEN product_quality NOT IN (0,1) OR product_quality IS NULL THEN 0 WHEN product_quality=1 AND NOT ("+valid("physical_quantity")+") THEN 0 ELSE 1 END)=1 " +"CASE WHEN MIN(CASE WHEN product_quality NOT IN (0,1) OR product_quality IS NULL THEN 0 WHEN product_quality=1 AND NOT ("+valid("physical_quantity")+") THEN 0 ELSE 1 END)=1 "
+"THEN SUM(CASE WHEN product_quality=1 THEN physical_quantity ELSE 0 END) END defective " +"THEN SUM(CASE WHEN product_quality=1 THEN physical_quantity ELSE 0 END) END defective "
+"FROM ll_selfop_inventory_snapshot WHERE "+scope(p,"")+" AND (snapshot_run_id=#{p.inventoryRun} OR snapshot_run_id IN ("+sampleRuns(p)+")) " +"FROM ll_selfop_inventory_snapshot WHERE "+scope(p,"")+" AND snapshot_run_id=#{p.inventoryRun} "
+"GROUP BY snapshot_run_id,customer_code,sku_code"; +"GROUP BY snapshot_run_id,customer_code,sku_code";
String profiles="SELECT * FROM ll_selfop_sku_profile WHERE "+scope(p,"")+" AND (profile_run_id IN (#{p.salesRun},#{p.inventoryRun}) OR profile_run_id IN ("+sampleRuns(p)+"))"; String profiles="SELECT * FROM ll_selfop_sku_profile WHERE "+scope(p,"")+" AND profile_run_id IN (#{p.salesRun},#{p.inventoryRun})";
StringBuilder sql=new StringBuilder("WITH calendar AS (").append(calendar(p)).append("), fixed_profiles AS (").append(profiles) String history="SELECT d.day_version_id,d.customer_code,d.sku_code,CASE WHEN MIN(CASE WHEN d.quality_status='VALID' AND d.product_quality IN (0,1) AND d.physical_quantity>=0 THEN 1 ELSE 0 END)=1 THEN SUM(d.physical_quantity) END physical,GROUP_CONCAT(DISTINCT d.quality_reason SEPARATOR ',') reason FROM ll_selfop_inventory_daily d JOIN ll_selfop_inventory_day_version v ON v.id=d.day_version_id WHERE v.source_account=#{p.query.sourceAccount} AND v.warehouse_code=#{p.query.warehouseCode} AND v.rule_version='FLOW_EOD_V1' AND v.invalidated_at IS NULL AND "+scope(p,"d.").replace("d.source_account=#{p.query.sourceAccount} AND d.warehouse_code=#{p.query.warehouseCode}","1=1")+" AND d.day_version_id IN ("+historyVersions(p)+") GROUP BY d.day_version_id,d.customer_code,d.sku_code";
StringBuilder sql=new StringBuilder("WITH calendar AS (").append(calendar(p)).append("), history AS (").append(history).append("), fixed_profiles AS (").append(profiles)
.append("), stocks AS (").append(stocks).append("), sales_days AS (").append(salesDays(p)).append("), candidates AS (") .append("), stocks AS (").append(stocks).append("), sales_days AS (").append(salesDays(p)).append("), candidates AS (")
.append("SELECT customer_code,sku_code FROM fixed_profiles UNION SELECT customer_code,sku_code FROM stocks UNION SELECT customer_code,sku_code FROM sales_days), profiles AS (") .append("SELECT customer_code,sku_code FROM fixed_profiles UNION SELECT customer_code,sku_code FROM stocks UNION SELECT customer_code,sku_code FROM sales_days UNION SELECT customer_code,sku_code FROM history), profiles AS (")
.append("SELECT c.customer_code,c.sku_code,COALESCE(MAX(CASE WHEN f.profile_run_id=#{p.inventoryRun} THEN f.product_name END),MAX(f.product_name)) product_name,") .append("SELECT c.customer_code,c.sku_code,COALESCE(MAX(CASE WHEN f.profile_run_id=#{p.inventoryRun} THEN f.product_name END),MAX(f.product_name)) product_name,")
.append("COALESCE(MAX(CASE WHEN f.profile_run_id=#{p.salesRun} THEN CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END END),MAX(CASE WHEN f.profile_run_id=#{p.inventoryRun} THEN CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END END),MIN(CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END)) unit_valid,") .append("COALESCE(MAX(CASE WHEN f.profile_run_id=#{p.salesRun} THEN CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END END),MAX(CASE WHEN f.profile_run_id=#{p.inventoryRun} THEN CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END END),MIN(CASE WHEN f.profile_run_id IS NULL THEN NULL WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END),CASE WHEN EXISTS (SELECT 1 FROM history h WHERE h.customer_code=c.customer_code AND h.sku_code=c.sku_code AND h.physical IS NOT NULL) THEN 1 ELSE 0 END) unit_valid,")
.append("MIN(CASE WHEN f.evidence_run_id IS NOT NULL AND f.evidence_type IN ('VALID_OUTBOUND','INVENTORY_OBSERVATION') THEN f.earliest_evidence_at_utc END) evidence ") .append("MIN(CASE WHEN f.evidence_run_id IS NOT NULL AND f.evidence_type IN ('VALID_OUTBOUND','INVENTORY_OBSERVATION') THEN f.earliest_evidence_at_utc END) evidence ")
.append("FROM candidates c LEFT JOIN fixed_profiles f ON f.customer_code=c.customer_code AND f.sku_code=c.sku_code GROUP BY c.customer_code,c.sku_code)") .append("FROM candidates c LEFT JOIN fixed_profiles f ON f.customer_code=c.customer_code AND f.sku_code=c.sku_code GROUP BY c.customer_code,c.sku_code)")
.append(InventoryVolumeSql.products()).append(skuWindows(p)).append(", last_sales AS (SELECT customer_code,sku_code,MAX(business_date) last_outbound FROM ll_selfop_sku_daily_sales WHERE ") .append(InventoryVolumeSql.products()).append(skuWindows(p)).append(", last_sales AS (SELECT customer_code,sku_code,MAX(business_date) last_outbound FROM ll_selfop_sku_daily_sales WHERE ")
@@ -88,7 +89,7 @@ public final class InventoryTurnoverSql {
sql.append(" FROM profiles c JOIN sku_windows w ON w.customer_code=c.customer_code AND w.sku_code=c.sku_code LEFT JOIN last_sales ls ON ls.customer_code=c.customer_code AND ls.sku_code=c.sku_code LEFT JOIN stocks st ON st.snapshot_run_id=#{p.inventoryRun} AND st.customer_code=c.customer_code AND st.sku_code=c.sku_code ") sql.append(" FROM profiles c JOIN sku_windows w ON w.customer_code=c.customer_code AND w.sku_code=c.sku_code LEFT JOIN last_sales ls ON ls.customer_code=c.customer_code AND ls.sku_code=c.sku_code LEFT JOIN stocks st ON st.snapshot_run_id=#{p.inventoryRun} AND st.customer_code=c.customer_code AND st.sku_code=c.sku_code ")
.append("LEFT JOIN product_volumes pv ON pv.customer_code=c.customer_code AND pv.sku_code=c.sku_code LEFT JOIN fixed_profiles ip ON ip.profile_run_id=#{p.inventoryRun} AND ip.customer_code=c.customer_code AND ip.sku_code=c.sku_code), sku_calculated AS (SELECT b.*,") .append("LEFT JOIN product_volumes pv ON pv.customer_code=c.customer_code AND pv.sku_code=c.sku_code LEFT JOIN fixed_profiles ip ON ip.profile_run_id=#{p.inventoryRun} AND ip.customer_code=c.customer_code AND ip.sku_code=c.sku_code), sku_calculated AS (SELECT b.*,")
.append("CASE WHEN available=0 THEN 0 WHEN available>0 AND w").append(p.query.days()).append("_outbound>0 THEN available*#{p.query.days}/w").append(p.query.days()).append("_outbound END coverage_days,") .append("CASE WHEN available=0 THEN 0 WHEN available>0 AND w").append(p.query.days()).append("_outbound>0 THEN available*#{p.query.days}/w").append(p.query.days()).append("_outbound END coverage_days,")
.append("CASE WHEN sample_total>0 AND evidence<=#{p.baseFromUtc} THEN w").append(p.query.days()).append("_outbound*#{p.query.days}/sample_total END turnover,") .append("CASE WHEN sample_total>0 THEN w").append(p.query.days()).append("_outbound*#{p.query.days}/sample_total END turnover,")
.append("CASE WHEN physical=0 THEN 0 WHEN physical>0 AND w"+p.query.days()+"_outbound IS NOT NULL THEN CASE WHEN w"+p.query.days()+"_outbound=0 THEN 1 ELSE 0 END END no_outbound,") .append("CASE WHEN physical=0 THEN 0 WHEN physical>0 AND w"+p.query.days()+"_outbound IS NOT NULL THEN CASE WHEN w"+p.query.days()+"_outbound=0 THEN 1 ELSE 0 END END no_outbound,")
.append("CASE WHEN physical IS NULL OR available IS NULL OR w").append(p.query.days()).append("_outbound IS NULL THEN 1 ELSE 0 END unassessable FROM sku_base b), sku_result AS (") .append("CASE WHEN physical IS NULL OR available IS NULL OR w").append(p.query.days()).append("_outbound IS NULL THEN 1 ELSE 0 END unassessable FROM sku_base b), sku_result AS (")
.append("SELECT b.*").append(InventoryVolumeSql.sku(p)).append(",CASE WHEN #{p.query.coverageDaysThreshold,jdbcType=INTEGER} IS NULL THEN NULL WHEN physical=0 OR available=0 THEN 0 WHEN coverage_days IS NOT NULL AND available>0 THEN CASE WHEN coverage_days>#{p.query.coverageDaysThreshold,jdbcType=INTEGER} THEN 1 ELSE 0 END WHEN available>0 AND w") .append("SELECT b.*").append(InventoryVolumeSql.sku(p)).append(",CASE WHEN #{p.query.coverageDaysThreshold,jdbcType=INTEGER} IS NULL THEN NULL WHEN physical=0 OR available=0 THEN 0 WHEN coverage_days IS NOT NULL AND available>0 THEN CASE WHEN coverage_days>#{p.query.coverageDaysThreshold,jdbcType=INTEGER} THEN 1 ELSE 0 END WHEN available>0 AND w")
@@ -127,9 +128,8 @@ public final class InventoryTurnoverSql {
// Read only actual qualified samples at the frozen date/run. Missing candidates // Read only actual qualified samples at the frozen date/run. Missing candidates
// remain in sku_zero_counts; absence never creates a zero-valued sample. // remain in sku_zero_counts; absence never creates a zero-valued sample.
sql.append(" FROM actual_sku_days GROUP BY customer_code,sku_code), sku_sample_days AS (SELECT st.customer_code,st.sku_code,d.business_date,") sql.append(" FROM actual_sku_days GROUP BY customer_code,sku_code), sku_sample_days AS (SELECT st.customer_code,st.sku_code,d.business_date,")
.append("st.physical sample_physical FROM calendar d JOIN stocks st ON st.snapshot_run_id=d.sample_run ") .append("st.physical sample_physical FROM calendar d JOIN history st ON st.day_version_id=d.sample_run ")
.append("JOIN fixed_profiles ip ON ip.profile_run_id=d.sample_run AND ip.customer_code=st.customer_code AND ip.sku_code=st.sku_code ") .append("WHERE st.physical IS NOT NULL), sku_sample_counts AS (SELECT customer_code,sku_code,")
.append("WHERE ip.quantity_unit='PCS' AND st.physical IS NOT NULL), sku_sample_counts AS (SELECT customer_code,sku_code,")
.append("COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count,") .append("COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count,")
// Keep the qualified sum exact; do not divide again by a rounded daily mean. // Keep the qualified sum exact; do not divide again by a rounded daily mean.
.append("CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) END sample_total,") .append("CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) END sample_total,")
@@ -146,11 +146,11 @@ public final class InventoryTurnoverSql {
// Qualified date membership supports the full selected window without numeric bit overflow. // Qualified date membership supports the full selected window without numeric bit overflow.
return "GROUP_CONCAT(CASE WHEN sample_physical IS NOT NULL THEN CAST(business_date AS CHAR) END ORDER BY business_date SEPARATOR ',')"; return "GROUP_CONCAT(CASE WHEN sample_physical IS NOT NULL THEN CAST(business_date AS CHAR) END ORDER BY business_date SEPARATOR ',')";
} }
private static String pageMissingDates() { private static String pageMissingDates(Input p) {
// Only the selected page expands dates. Numeric qualification and filtering // Only the selected page expands dates. Numeric qualification and filtering
// already used every candidate and every frozen sample before pagination. // already used every candidate and every frozen sample before pagination.
return ", page_missing AS (SELECT r.*, (SELECT GROUP_CONCAT(CAST(business_date AS CHAR) ORDER BY business_date SEPARATOR ',') FROM calendar d " return ", page_missing AS (SELECT r.*, (SELECT GROUP_CONCAT(CAST(business_date AS CHAR) ORDER BY business_date SEPARATOR ',') FROM calendar d "
+"WHERE d.business_date>=CAST(#{p.baseFrom} AS DATE) AND LOCATE(CAST(d.business_date AS CHAR),COALESCE(r.sample_mask,''))=0) missing_dates FROM page_result r)"; +"WHERE d.business_date>=CAST(#{p.baseFrom} AS DATE) AND LOCATE(CAST(d.business_date AS CHAR),COALESCE(r.sample_mask,''))=0) missing_dates, (SELECT GROUP_CONCAT(DISTINCT h.reason SEPARATOR ',') FROM history h JOIN calendar d ON d.sample_run=h.day_version_id WHERE h.customer_code=r.customer_code "+("SKU".equals(p.query.group())?"AND h.sku_code=r.sku_code ":"")+"AND d.business_date>=CAST(#{p.baseFrom} AS DATE)) history_reasons FROM page_result r)";
} }
private static String windowAggregates(Input p) { private static String windowAggregates(Input p) {
StringBuilder sql=new StringBuilder(); StringBuilder sql=new StringBuilder();
@@ -243,7 +243,7 @@ public final class InventoryTurnoverSql {
} }
public static String page(Map<String,Object> args) { public static String page(Map<String,Object> args) {
Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()); Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group());
return cte(p)+(owner?ownerCte(p):"")+", page_result AS (SELECT * FROM "+(owner?"owner_result":"sku_result")+filter(p,owner)+order(p,owner)+" LIMIT #{p.query.pageSize} OFFSET #{p.query.offset})"+pageMissingDates()+" SELECT * FROM page_missing"+order(p,owner); return cte(p)+(owner?ownerCte(p):"")+", page_result AS (SELECT * FROM "+(owner?"owner_result":"sku_result")+filter(p,owner)+order(p,owner)+" LIMIT #{p.query.pageSize} OFFSET #{p.query.offset})"+pageMissingDates(p)+" SELECT * FROM page_missing"+order(p,owner);
} }
public static String count(Map<String,Object> args) { public static String count(Map<String,Object> args) {
Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()); Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group());
@@ -270,7 +270,7 @@ public final class InventoryTurnoverSql {
public static String report(Map<String,Object> args) { public static String report(Map<String,Object> args) {
Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()),overview=p.query.overview(); Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()),overview=p.query.overview();
String source=owner?"owner_result":"sku_result"; String source=owner?"owner_result":"sku_result";
List<String> fields=new ArrayList<>(List.of("customer_code","physical","available","frozen","defective","transit","sample_count","average_physical","missing_dates","last_outbound","turnover","unassessable")); List<String> fields=new ArrayList<>(List.of("customer_code","physical","available","frozen","defective","transit","sample_count","average_physical","missing_dates","history_reasons","last_outbound","turnover","unassessable"));
for(int n:p.query().windows())for(String suffix:List.of("outbound","observed","complete","partial"))fields.add("w"+n+"_"+suffix); for(int n:p.query().windows())for(String suffix:List.of("outbound","observed","complete","partial"))fields.add("w"+n+"_"+suffix);
fields.addAll(InventoryVolumeSql.fields(p)); fields.addAll(InventoryVolumeSql.fields(p));
if(owner) { if(owner) {
@@ -281,7 +281,7 @@ public final class InventoryTurnoverSql {
// Stock charts share owner totals, independently of list filters and pagination. // Stock charts share owner totals, independently of list filters and pagination.
String sql=cte(p)+(owner?ownerCte(p):overview?", owner_totals AS ("+totals(p,"customer_code",true)+" FROM sku_result GROUP BY customer_code) ":"") String sql=cte(p)+(owner?ownerCte(p):overview?", owner_totals AS ("+totals(p,"customer_code",true)+" FROM sku_result GROUP BY customer_code) ":"")
+", page_result AS (SELECT r.*,ROW_NUMBER() OVER ("+order(p,owner)+") ordinal FROM "+source+" r"+filter(p,owner)+order(p,owner)+" LIMIT #{p.query.pageSize} OFFSET #{p.query.offset})"; +", page_result AS (SELECT r.*,ROW_NUMBER() OVER ("+order(p,owner)+") ordinal FROM "+source+" r"+filter(p,owner)+order(p,owner)+" LIMIT #{p.query.pageSize} OFFSET #{p.query.offset})";
sql+=pageMissingDates(); sql+=pageMissingDates(p);
if(overview)sql+=", summary_result AS ("+totals(p,null,false)+" FROM sku_result), top_owners AS (SELECT customer_code,physical,ROW_NUMBER() OVER (ORDER BY physical DESC,customer_code) ordinal FROM owner_totals WHERE physical IS NOT NULL ORDER BY physical DESC,customer_code LIMIT 10)" if(overview)sql+=", summary_result AS ("+totals(p,null,false)+" FROM sku_result), top_owners AS (SELECT customer_code,physical,ROW_NUMBER() OVER (ORDER BY physical DESC,customer_code) ordinal FROM owner_totals WHERE physical IS NOT NULL ORDER BY physical DESC,customer_code LIMIT 10)"
+", top_volume_owners AS (SELECT t.*,ROW_NUMBER() OVER (ORDER BY physical_volume DESC,customer_code) ordinal FROM owner_totals t WHERE physical_volume IS NOT NULL ORDER BY physical_volume DESC,customer_code LIMIT 10)"; +", top_volume_owners AS (SELECT t.*,ROW_NUMBER() OVER (ORDER BY physical_volume DESC,customer_code) ordinal FROM owner_totals t WHERE physical_volume IS NOT NULL ORDER BY physical_volume DESC,customer_code LIMIT 10)";
// Statement-local only: share intermediate relations across report branches; // Statement-local only: share intermediate relations across report branches;
@@ -40,7 +40,18 @@ public class SelfopEgressService {
if(business!=null){verifyBusinessTask(task,root);String limit=business.limit(now);if(limit!=null){stopBusiness(root,business,limit,now);throw reject(limit);}} if(business!=null){verifyBusinessTask(task,root);String limit=business.limit(now);if(limit!=null){stopBusiness(root,business,limit,now);throw reject(limit);}}
verify(task,run,root,account,now); verify(task,run,root,account,now);
boolean flowOperation=operation==Operation.INVENTORY_FLOW; boolean flowOperation=operation==Operation.INVENTORY_FLOW;
if(flowOperation!="INVENTORY_FLOW_PROBE".equals(task.getTaskType()) boolean history=com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice.TYPE.equals(task.getTaskType());
if(history){
if(!flowOperation||!config.isInventoryHistoryEnabled()||!SelfopCollectionPolicy.continuousBusinessRoot(task)
||!SelfopCollectionPolicy.continuousBusiness(config)||pageSize!=1000)throw reject("EGRESS_HISTORY_INVALID");
try {
var slice=SelfopSupport.read(task.getQueryScopeJson(),com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice.class);
var checkpoint=SelfopSupport.read(run.getProgressSnapshotJson(),com.fasterxml.jackson.databind.JsonNode.class);
int page=checkpoint.path("nextPage").asInt();
if(!slice.warehouse().equals(warehouseCode)||!config.getWarehouses().containsKey(warehouseCode)
||!slice.requestHash(page).equals(requestHash))throw reject("EGRESS_HISTORY_INVALID");
}catch(EgressRejectedException e){throw e;}catch(RuntimeException invalid){throw reject("EGRESS_HISTORY_INVALID");}
}else if(flowOperation!="INVENTORY_FLOW_PROBE".equals(task.getTaskType())
||flowOperation&&(!"test".equals(config.getEnvironment())||!"BUSINESS".equals(task.getExecutionPurpose()) ||flowOperation&&(!"test".equals(config.getEnvironment())||!"BUSINESS".equals(task.getExecutionPurpose())
||pageSize!=1000||!config.getWarehouses().containsKey(warehouseCode) ||pageSize!=1000||!config.getWarehouses().containsKey(warehouseCode)
||business==null||business.http!=0||!Objects.equals(task.getRequestHash(),requestHash)))throw reject("EGRESS_FLOW_PROBE_INVALID"); ||business==null||business.http!=0||!Objects.equals(task.getRequestHash(),requestHash)))throw reject("EGRESS_FLOW_PROBE_INVALID");
@@ -79,6 +90,7 @@ public class SelfopEgressService {
var account=account(c.sourceAccount());var budget=budget(account,now,c.executionPurpose());var pending=pendingForAuthorization(budget,c,request.operation()); var account=account(c.sourceAccount());var budget=budget(account,now,c.executionPurpose());var pending=pendingForAuthorization(budget,c,request.operation());
var root=root(parseId(c.rootTaskId()),c.executionPurpose(),c.sourceAccount()); var root=root(parseId(c.rootTaskId()),c.executionPurpose(),c.sourceAccount());
var task=mapper.lockTask(pending.taskId);var run=mapper.lockRun(pending.runId); var task=mapper.lockTask(pending.taskId);var run=mapper.lockRun(pending.runId);
if(com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice.TYPE.equals(task==null?null:task.getTaskType())&&!config.isInventoryHistoryEnabled())throw reject("EGRESS_HISTORY_DISABLED");
if(hasBusinessSession(root)){ if(hasBusinessSession(root)){
var b=business(root);verifyBusinessTask(task,root); var b=business(root);verifyBusinessTask(task,root);
// Explicit root stops remain independent of request accounting and short permit expiry. // Explicit root stops remain independent of request accounting and short permit expiry.
@@ -19,6 +19,7 @@ public interface SalesRetentionMapper {
AND r.superseded_at_utc IS NOT NULL AND r.superseded_at_utc <= #{cutoff} AND r.superseded_at_utc IS NOT NULL AND r.superseded_at_utc <= #{cutoff}
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 NOT EXISTS (SELECT 1 FROM ll_selfop_inventory_day_version h WHERE h.sales_run_id=r.id OR h.anchor_run_id=r.id)
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_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))
@@ -32,10 +33,11 @@ public interface SalesRetentionMapper {
List<SelfopSyncRun> eligible(@Param("scopeId") Long scopeId,@Param("current") Long current, List<SelfopSyncRun> eligible(@Param("scopeId") Long scopeId,@Param("current") Long current,
@Param("previous") Long previous,@Param("cutoff") LocalDateTime cutoff); @Param("previous") Long previous,@Param("cutoff") LocalDateTime cutoff);
@Select(""" @Select("""
SELECT COUNT(*) FROM ll_selfop_sync_run input JOIN ll_selfop_sync_task t ON t.id=input.task_id SELECT (SELECT COUNT(*) FROM ll_selfop_sync_run input JOIN ll_selfop_sync_task t ON t.id=input.task_id
WHERE t.source_account=#{account} AND t.warehouse_code=#{warehouse} WHERE t.source_account=#{account} AND t.warehouse_code=#{warehouse}
AND (t.status NOT IN ('SUCCEEDED','SAMPLE_DONE','CANCELLED') OR t.apply_state IN ('APPLYING','BLOCKED')) AND (t.status NOT IN ('SUCCEEDED','SAMPLE_DONE','CANCELLED') OR t.apply_state IN ('APPLYING','BLOCKED'))
AND (input.base_published_run_id=#{id} OR input.reference_sales_run_id=#{id}) AND (input.base_published_run_id=#{id} OR input.reference_sales_run_id=#{id}))
+ (SELECT COUNT(*) FROM ll_selfop_inventory_day_version h WHERE h.sales_run_id=#{id} OR h.anchor_run_id=#{id})
""") """)
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}")
@@ -3715,7 +3715,7 @@ CREATE TABLE ll_selfop_sync_task (
observation_baseline BIGINT NULL COMMENT '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测', observation_baseline BIGINT NULL COMMENT '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测',
request_key VARCHAR(128) NOT NULL COMMENT '共享发现包含切片/计划槽位;分仓任务包含仓库、任务族及切片或计划槽位;人工附请求幂等键', request_key VARCHAR(128) NOT NULL COMMENT '共享发现包含切片/计划槽位;分仓任务包含仓库、任务族及切片或计划槽位;人工附请求幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求类型、查询范围、范围摘要的指纹;同键不同请求拒绝', request_hash CHAR(64) NOT NULL COMMENT '请求类型、查询范围、范围摘要的指纹;同键不同请求拒绝',
task_type VARCHAR(24) NOT NULL COMMENT 'SCHEDULED、MANUAL_RETRY、BACKFILL、RECHECK、REBUILD、VALIDATION;验证不由自动计划创建', task_type VARCHAR(24) NOT NULL COMMENT '计划、补采、复核、重建与验证;INVENTORY_FLOW_HISTORY为流水片,INVENTORY_HISTORY_BUILD为本地历史还原',
planned_at_utc DATETIME(3) NULL COMMENT '计划时点;人工请求可空', planned_at_utc DATETIME(3) NULL COMMENT '计划时点;人工请求可空',
query_from_utc DATETIME(3) NULL COMMENT '出库查询区间UTC下界,库存不填', query_from_utc DATETIME(3) NULL COMMENT '出库查询区间UTC下界,库存不填',
query_until_utc DATETIME(3) NULL COMMENT '出库查询区间UTC上界,不含端点,库存不填', query_until_utc DATETIME(3) NULL COMMENT '出库查询区间UTC上界,不含端点,库存不填',
@@ -3999,3 +3999,86 @@ CREATE TABLE ll_selfop_product (
UNIQUE KEY uk_selfop_product_version (run_id,source_account,customer_code,sku_code), UNIQUE KEY uk_selfop_product_version (run_id,source_account,customer_code,sku_code),
KEY idx_selfop_product_lookup (source_account,customer_code,sku_code,run_id) KEY idx_selfop_product_lookup (source_account,customer_code,sku_code,run_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星商品资料版本'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星商品资料版本';
-- 历史库存基线;执行前必须取得指定环境的建表授权。
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_flow (
id BIGINT NOT NULL COMMENT '应用生成的流水记录标识',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码',
customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码',
sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码',
source_flow_id VARCHAR(64) NOT NULL COMMENT '来源流水字符串标识',
product_quality INT NOT NULL COMMENT '来源品质,零正品一次品,其他未知',
cell_no VARCHAR(128) NULL COMMENT '来源库位编码',
inner_batch_no VARCHAR(128) NULL COMMENT '来源内部批次号',
bill_no VARCHAR(128) NULL COMMENT '来源业务单号',
source_no VARCHAR(128) NULL COMMENT '来源关联单号',
bill_type VARCHAR(32) NULL COMMENT '来源业务类型,未验证类型不得参与正式还原',
book_qty DECIMAL(20,6) NULL COMMENT '来源记账增减量,未知为空',
book_change_qty_raw VARCHAR(64) NULL COMMENT '来源带符号变动量原文',
delta_quantity DECIMAL(20,6) NULL COMMENT '解析后的有符号实物变动量',
close_quantity DECIMAL(20,6) NULL COMMENT '对应库位批次品质链的结存',
source_create_time VARCHAR(64) NULL COMMENT '来源北京时间原文',
event_at_utc DATETIME(3) NULL COMMENT '流水发生UTC时刻,无法解析时为空',
business_date DATE NULL COMMENT '北京时间业务日期',
quantity_unit VARCHAR(16) NULL COMMENT '已确认数量单位,未知为空',
content_hash CHAR(64) NOT NULL COMMENT '当前事实内容摘要',
revision_no BIGINT NOT NULL COMMENT '同来源流水单调增加的修订号',
stage_id BIGINT NOT NULL COMMENT '当前事实对应不可变暂存证据',
first_run_id BIGINT NOT NULL COMMENT '首次观察执行尝试',
last_run_id BIGINT NOT NULL COMMENT '最近确认执行尝试',
last_observed_at DATETIME(3) NOT NULL COMMENT '最近观察UTC时刻',
validation_status VARCHAR(16) NOT NULL COMMENT '字段资格,有效或未知',
validation_reason VARCHAR(64) NULL COMMENT '数量或身份待核验原因',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_flow_source (source_account,warehouse_code,source_flow_id),
KEY idx_selfop_flow_sku (source_account,warehouse_code,customer_code,sku_code,event_at_utc),
KEY idx_selfop_flow_day (source_account,warehouse_code,business_date),
CONSTRAINT chk_selfop_flow_revision CHECK (revision_no > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='自营历史库存流水最新事实,旧版本保留于暂存证据';
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_day_version (
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 '北京时间日末所属日期',
scope_hash CHAR(64) NOT NULL COMMENT '业务范围摘要',
rule_version VARCHAR(32) NOT NULL COMMENT '日末库存还原规则版本',
build_run_id BIGINT NOT NULL COMMENT '历史重建执行尝试',
publication_group_id BIGINT NOT NULL COMMENT '必须一起切换的发布组标识',
sales_run_id BIGINT NULL COMMENT '构建时固定的销量证据版本',
anchor_run_id BIGINT NOT NULL COMMENT '固定的独立库存锚点版本',
input_hash CHAR(64) NOT NULL COMMENT '冻结流水修订与覆盖及锚点输入摘要',
evidence_json JSON NOT NULL COMMENT '固定输入清单及不可变证据引用',
status VARCHAR(16) NOT NULL COMMENT '构建中、就绪、已发布、已替代或失败',
head_slot TINYINT NULL COMMENT '当前日头为一,非当前版本为空',
valid_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '合格商品数',
unknown_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '待核验商品数',
created_at DATETIME(3) NOT NULL COMMENT '版本创建UTC时刻',
published_at DATETIME(3) NULL COMMENT '发布组提交UTC时刻',
superseded_at DATETIME(3) NULL COMMENT '被新组替代的UTC时刻',
invalidated_at DATETIME(3) NULL COMMENT '已确认输入失效的UTC时刻',
invalidation_reason VARCHAR(64) NULL COMMENT '旧版本失效原因',
rebuild_run_id BIGINT NULL COMMENT '处理失效版本的重建尝试',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_history_head (source_account,warehouse_code,business_date,head_slot),
UNIQUE KEY uk_selfop_history_build_day (build_run_id,business_date),
KEY idx_selfop_history_group (publication_group_id,status),
CONSTRAINT chk_selfop_history_head CHECK (head_slot IS NULL OR (head_slot=1 AND status='PUBLISHED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日库存不可变版本及原子发布日头';
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_daily (
day_version_id BIGINT NOT NULL COMMENT '所属历史日版本',
customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码',
sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码',
product_quality INT NOT NULL COMMENT '来源库存品质',
physical_quantity DECIMAL(20,6) NULL COMMENT '日末实物库存,未知为空,已确认零为零',
quality_status VARCHAR(16) NOT NULL COMMENT '日末资格,有效或未知',
quality_reason VARCHAR(64) NULL COMMENT '缺覆盖或锚点等未知原因',
anchor_reference BIGINT NULL COMMENT '独立库存锚点版本',
evidence_hash CHAR(64) NOT NULL COMMENT '该日计算证据摘要',
source VARCHAR(32) NOT NULL COMMENT '库存来源标记,流水还原',
PRIMARY KEY (day_version_id,customer_code,sku_code,product_quality),
KEY idx_selfop_daily_identity (customer_code,sku_code,day_version_id),
CONSTRAINT chk_selfop_daily_quantity CHECK ((quality_status='VALID' AND physical_quantity IS NOT NULL AND physical_quantity>=0) OR (quality_status='UNKNOWN' AND physical_quantity IS NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日末分货主商品品质实物库存';
@@ -0,0 +1,82 @@
-- 历史库存基线;执行前必须取得指定环境的建表授权。
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_flow (
id BIGINT NOT NULL COMMENT '应用生成的流水记录标识',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码',
customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码',
sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码',
source_flow_id VARCHAR(64) NOT NULL COMMENT '来源流水字符串标识',
product_quality INT NOT NULL COMMENT '来源品质,零正品一次品,其他未知',
cell_no VARCHAR(128) NULL COMMENT '来源库位编码',
inner_batch_no VARCHAR(128) NULL COMMENT '来源内部批次号',
bill_no VARCHAR(128) NULL COMMENT '来源业务单号',
source_no VARCHAR(128) NULL COMMENT '来源关联单号',
bill_type VARCHAR(32) NULL COMMENT '来源业务类型,未验证类型不得参与正式还原',
book_qty DECIMAL(20,6) NULL COMMENT '来源记账增减量,未知为空',
book_change_qty_raw VARCHAR(64) NULL COMMENT '来源带符号变动量原文',
delta_quantity DECIMAL(20,6) NULL COMMENT '解析后的有符号实物变动量',
close_quantity DECIMAL(20,6) NULL COMMENT '对应库位批次品质链的结存',
source_create_time VARCHAR(64) NULL COMMENT '来源北京时间原文',
event_at_utc DATETIME(3) NULL COMMENT '流水发生UTC时刻,无法解析时为空',
business_date DATE NULL COMMENT '北京时间业务日期',
quantity_unit VARCHAR(16) NULL COMMENT '已确认数量单位,未知为空',
content_hash CHAR(64) NOT NULL COMMENT '当前事实内容摘要',
revision_no BIGINT NOT NULL COMMENT '同来源流水单调增加的修订号',
stage_id BIGINT NOT NULL COMMENT '当前事实对应不可变暂存证据',
first_run_id BIGINT NOT NULL COMMENT '首次观察执行尝试',
last_run_id BIGINT NOT NULL COMMENT '最近确认执行尝试',
last_observed_at DATETIME(3) NOT NULL COMMENT '最近观察UTC时刻',
validation_status VARCHAR(16) NOT NULL COMMENT '字段资格,有效或未知',
validation_reason VARCHAR(64) NULL COMMENT '数量或身份待核验原因',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_flow_source (source_account,warehouse_code,source_flow_id),
KEY idx_selfop_flow_sku (source_account,warehouse_code,customer_code,sku_code,event_at_utc),
KEY idx_selfop_flow_day (source_account,warehouse_code,business_date),
CONSTRAINT chk_selfop_flow_revision CHECK (revision_no > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='自营历史库存流水最新事实,旧版本保留于暂存证据';
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_day_version (
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 '北京时间日末所属日期',
scope_hash CHAR(64) NOT NULL COMMENT '业务范围摘要',
rule_version VARCHAR(32) NOT NULL COMMENT '日末库存还原规则版本',
build_run_id BIGINT NOT NULL COMMENT '历史重建执行尝试',
publication_group_id BIGINT NOT NULL COMMENT '必须一起切换的发布组标识',
sales_run_id BIGINT NULL COMMENT '构建时固定的销量证据版本',
anchor_run_id BIGINT NOT NULL COMMENT '固定的独立库存锚点版本',
input_hash CHAR(64) NOT NULL COMMENT '冻结流水修订与覆盖及锚点输入摘要',
evidence_json JSON NOT NULL COMMENT '固定输入清单及不可变证据引用',
status VARCHAR(16) NOT NULL COMMENT '构建中、就绪、已发布、已替代或失败',
head_slot TINYINT NULL COMMENT '当前日头为一,非当前版本为空',
valid_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '合格商品数',
unknown_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '待核验商品数',
created_at DATETIME(3) NOT NULL COMMENT '版本创建UTC时刻',
published_at DATETIME(3) NULL COMMENT '发布组提交UTC时刻',
superseded_at DATETIME(3) NULL COMMENT '被新组替代的UTC时刻',
invalidated_at DATETIME(3) NULL COMMENT '已确认输入失效的UTC时刻',
invalidation_reason VARCHAR(64) NULL COMMENT '旧版本失效原因',
rebuild_run_id BIGINT NULL COMMENT '处理失效版本的重建尝试',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_history_head (source_account,warehouse_code,business_date,head_slot),
UNIQUE KEY uk_selfop_history_build_day (build_run_id,business_date),
KEY idx_selfop_history_group (publication_group_id,status),
CONSTRAINT chk_selfop_history_head CHECK (head_slot IS NULL OR (head_slot=1 AND status='PUBLISHED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日库存不可变版本及原子发布日头';
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_daily (
day_version_id BIGINT NOT NULL COMMENT '所属历史日版本',
customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码',
sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码',
product_quality INT NOT NULL COMMENT '来源库存品质',
physical_quantity DECIMAL(20,6) NULL COMMENT '日末实物库存,未知为空,已确认零为零',
quality_status VARCHAR(16) NOT NULL COMMENT '日末资格,有效或未知',
quality_reason VARCHAR(64) NULL COMMENT '缺覆盖或锚点等未知原因',
anchor_reference BIGINT NULL COMMENT '独立库存锚点版本',
evidence_hash CHAR(64) NOT NULL COMMENT '该日计算证据摘要',
source VARCHAR(32) NOT NULL COMMENT '库存来源标记,流水还原',
PRIMARY KEY (day_version_id,customer_code,sku_code,product_quality),
KEY idx_selfop_daily_identity (customer_code,sku_code,day_version_id),
CONSTRAINT chk_selfop_daily_quantity CHECK ((quality_status='VALID' AND physical_quantity IS NOT NULL AND physical_quantity>=0) OR (quality_status='UNKNOWN' AND physical_quantity IS NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日末分货主商品品质实物库存';
@@ -6,12 +6,12 @@ info:
仅PLATFORM、tenantId=0、非代登录及platform:self-operated-analytics:list权限。 仅PLATFORM、tenantId=0、非代登录及platform:self-operated-analytics:list权限。
仓库由服务端受控配置校验;不触发采集,不读取来源订单,不调用领星。 仓库由服务端受控配置校验;不触发采集,不读取来源订单,不调用领星。
所选窗口为截至endDate的1~366个完整北京时间自然日,并提供7/15/30天辅助对比;当前库存独立固定版本。 所选窗口为截至endDate的1~366个完整北京时间自然日,并提供7/15/30天辅助对比;当前库存独立固定版本。
每次请求使用REPEATABLE_READ。snapshotContext为服务端重建验证的版本与日代表证据, 每次请求使用REPEATABLE_READ,一致性读固定销量、当前库存及历史日版本映射。
不作为授权凭据;账号、仓库、截止日、基准、queryAsOf、版本对、范围摘要、 snapshotContext保存签名压缩的准确版本清单与缺失日期,不作为授权凭据;后续页按准确ID校验
重建状态及全部max(30,baseWindowDays)日映射(含缺失日期)逐项重建比较;无效或过期要求整体刷新。 账号、仓库、范围及失效标记,不用墙钟时刻重新推导日头。服务重启、校正失效、规则变更要求整体刷新。
历史代表须同范围、正式计划采集、已发布且DAILY_SCHEDULED_FIRST_V1合格标记。 历史分母只使用FLOW_EOD_V1日末全品质实物库存,以完整流水及独立库存锚点还原;不与旧计划样本混算。
上下文有效期最多30分钟;客户端不能直接指定runId。SKU/关注分页不改变概览范围。 上下文有效期最多30分钟;客户端不能直接指定runId。SKU/关注分页不改变概览范围。
历史销量完整性不依赖当前库存新鲜度;缺样本不补零、不缩短周转分母。 历史销量完整性不依赖当前库存新鲜度;缺日保持未知、不缩短周转分母。sampleReasonCodes包含流水或独立库存对账失败原因。dailySamples.dayVersionId表示历史日版本标识,非原始库存run。
security: [{bearerAuth: []}] security: [{bearerAuth: []}]
paths: paths:
/api/platform/v1/last-leg/self-operated-analytics/inventory-turnover: /api/platform/v1/last-leg/self-operated-analytics/inventory-turnover:
@@ -143,17 +143,17 @@ components:
analyticsContext: {$ref: './self-operated-analytics-v1.yaml#/components/schemas/Context'} analyticsContext: {$ref: './self-operated-analytics-v1.yaml#/components/schemas/Context'}
inventoryObservedFrom: {type: string, format: date-time, nullable: true} inventoryObservedFrom: {type: string, format: date-time, nullable: true}
inventoryObservedUntil: {type: string, format: date-time, nullable: true} inventoryObservedUntil: {type: string, format: date-time, nullable: true}
sampleRuleVersion: {type: string, enum: [DAILY_SCHEDULED_FIRST_V1]} sampleRuleVersion: {type: string, enum: [FLOW_EOD_V1]}
dailySamples: dailySamples:
type: array type: array
minItems: 30 minItems: 30
maxItems: 366 maxItems: 366
items: items:
type: object type: object
required: [date, runId] required: [date, dayVersionId]
properties: properties:
date: {type: string, format: date} date: {type: string, format: date}
runId: {type: string, nullable: true, description: 内部版本证据,不作为用户可见编号} dayVersionId: {type: string, nullable: true, description: 内部版本证据,不作为用户可见编号}
missingSampleDates: {type: array, items: {type: string, format: date}} missingSampleDates: {type: array, items: {type: string, format: date}}
accumulationStartedOn: {type: string, format: date, nullable: true} accumulationStartedOn: {type: string, format: date, nullable: true}
Row: Row:
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.Test;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
class InventoryFlowSliceTest {
private final InventoryFlowSlice slice=new InventoryFlowSlice("W",LocalDateTime.parse("2026-10-14T00:00:00"),LocalDateTime.parse("2026-10-15T00:00:00"),1000);
JsonNode row(String id,String at){return SelfopSupport.read(SelfopSupport.json(Map.of("id",id,"customerCode","C","whCode","W","productSku","SKU","createTime",at)),JsonNode.class);}
JsonNode page(int current,int total,List<JsonNode> rows){return SelfopSupport.read(SelfopSupport.json(Map.of("current",current,"size",1000,"total",total,"pages",total==0?0:(total-1)/1000+1,"records",rows)),JsonNode.class);}
@Test void emptyRangeAndInclusiveMidnightArePreserved(){
assertEquals(0,slice.validate(page(1,0,List.of()),1,null,null).total());
assertEquals(1,slice.validate(page(1,1,List.of(row("1","2026-10-15 00:00:00"))),1,null,null).rows().size());
assertEquals(Instant.parse("2026-10-14T16:00:01Z"),slice.until());
}
@Test void totalDriftDuplicatesAndOrderingNeverProveCoverage(){
assertThrows(IllegalStateException.class,()->slice.validate(page(1,1,List.of(row("1","2026-10-14 12:00:00"))),1,2L,null));
assertThrows(IllegalStateException.class,()->slice.validate(page(1,2,List.of(row("1","2026-10-14 12:00:00"),row("1","2026-10-14 12:00:00"))),1,null,null));
assertThrows(IllegalStateException.class,()->slice.validate(page(1,1,List.of(row("1","2026-10-14 12:00:00"))),1,null,LocalDateTime.parse("2026-10-14T13:00:00")));
assertThrows(IllegalStateException.class,()->slice.validate(page(1,2,List.of(row("1","2026-10-14 12:00:00"))),1,null,null));
assertThrows(IllegalStateException.class,()->slice.validate(page(1,1,List.of(row("1","2026-10-15 00:00:01"))),1,null,null));
}
@Test void fixedPageHashCannotChangeWarehouseWindowOrPage(){
assertEquals(slice.requestHash(1),SelfopSupport.hash(slice.request(1)));
assertNotEquals(slice.requestHash(1),slice.requestHash(2));
assertThrows(IllegalArgumentException.class,()->new InventoryFlowSlice("W",slice.start(),slice.end().plusDays(1),1000));
assertThrows(IllegalArgumentException.class,()->new InventoryFlowSlice("W",slice.start(),slice.end(),100));
}
@Test void acceptsInternalLongStringButRejectsFractionalOrOverflowTotals(){
var response=(com.fasterxml.jackson.databind.node.ObjectNode)page(1,0,List.of());
response.put("total","0");
assertEquals(0,slice.validate(response,1,null,null).total());
for(String invalid:List.of("0.0","-1","9223372036854775808","1e3","")) {
response.put("total",invalid);
assertThrows(IllegalStateException.class,()->slice.validate(response,1,null,null));
}
response.put("total",0.5);
assertThrows(IllegalStateException.class,()->slice.validate(response,1,null,null));
}
}
@@ -0,0 +1,85 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import static org.assertj.core.api.Assertions.assertThat;
import static com.baiyue.lastleg.selfoperated.analytics.history.InventoryHistoryCalculator.*;
class InventoryHistoryCalculatorTest {
private static final Sku SKU=new Sku("OWNER","SKU");
private static final Key GOOD=new Key(SKU,0), BAD=new Key(SKU,1);
private static final LocalDate DAY=LocalDate.of(2026,10,14);
private static Instant at(String local) { return LocalDateTime.parse(local).toInstant(ZoneOffset.ofHours(8)); }
private static BigDecimal n(String n) { return new BigDecimal(n); }
private static Anchor anchor(Key k,String qty) { return new Anchor(k,n(qty),"PCS",at("2026-10-15T08:00:00"),at("2026-10-15T08:00:01"),100); }
private static Flow flow(String id,Key key,String type,String delta,String close,String time) {
return new Flow(id,key,"CELL","BATCH",type,n(delta),n(delta),n(close),at(time));
}
private static Input input(List<Flow> rows,List<Anchor> anchors) {
return new Input(DAY,DAY.plusDays(1),Set.of(SKU),rows,anchors,
List.of(new Interval(at("2026-10-14T00:00:00"),at("2026-10-15T08:00:02"))));
}
@Test void reversesTailAndAssignsMidnightToNewDay() {
var r=reconstruct(input(List.of(flow("f",GOOD,"DO","-1","144","2026-10-15T00:00:00")),List.of(anchor(GOOD,"144"))));
assertThat(r.get(0).quantity()).isEqualByComparingTo("145");
}
@Test void missingTailIsUnknownEvenWhenYesterdayIsComplete() {
var i=input(List.of(),List.of(anchor(GOOD,"144")));
var r=reconstruct(new Input(i.from(),i.until(),i.candidates(),i.flows(),i.anchors(),List.of(new Interval(at("2026-10-14T00:00:00"),at("2026-10-15T00:00:00")))));
assertThat(r.get(0).reason()).isEqualTo("FLOW_COVERAGE_GAP");assertThat(r.get(0).quantity()).isNull();
}
@Test void keepsInactiveBatchInSkuAnchor() {
var r=reconstruct(input(List.of(flow("f",GOOD,"DO","-1","10","2026-10-15T01:00:00")),List.of(anchor(GOOD,"100"))));
assertThat(r.get(0).quantity()).isEqualByComparingTo("101");
}
@Test void sameIdAtOverlappingBoundaryIsCountedOnce() {
var f=flow("f",GOOD,"DO","-1","9","2026-10-15T01:00:00");
assertThat(reconstruct(input(List.of(f,f),List.of(anchor(GOOD,"9")))).get(0).quantity()).isEqualByComparingTo("10");
}
@Test void sameIdDifferentIdentityRevokesBoth() {
var f=flow("f",GOOD,"DO","-1","9","2026-10-15T01:00:00");
var g=flow("f",BAD,"IMO","1","1","2026-10-15T01:00:00");
assertThat(reconstruct(input(List.of(f,g),List.of(anchor(GOOD,"9"),anchor(BAD,"1"))))).allMatch(d->"FLOW_ID_CONFLICT".equals(d.reason()));
}
@Test void qualityConversionPreservesTotalWithoutDroppingEitherSide() {
var r=reconstruct(input(List.of(flow("a",GOOD,"IMO","-3","7","2026-10-15T01:00:00"),flow("b",BAD,"IMO","3","3","2026-10-15T01:00:00")),List.of(anchor(GOOD,"7"),anchor(BAD,"3"))));
assertThat(r.stream().map(Daily::quantity).reduce(BigDecimal.ZERO,BigDecimal::add)).isEqualByComparingTo("10");
assertThat(r.get(0).quantity()).isEqualByComparingTo("10");assertThat(r.get(1).quantity()).isZero();
}
@Test void absentAnchorNeverBecomesZero() {
assertThat(reconstruct(input(List.of(),List.of())).get(0).reason()).isEqualTo("ANCHOR_MISSING");
}
@Test void explicitZeroWithCompleteCoverageIsValid() {
var d=reconstruct(input(List.of(),List.of(anchor(GOOD,"0")))).get(0);
assertThat(d.status()).isEqualTo("VALID");assertThat(d.quantity()).isZero();
}
@Test void unverifiedTypeIsNotIgnored() {
assertThat(reconstruct(input(List.of(flow("f",GOOD,"BO","1","1","2026-10-15T01:00:00")),List.of(anchor(GOOD,"1")))).get(0).reason()).isEqualTo("FLOW_TYPE_UNVERIFIED");
}
@Test void eventInAnchorObservationIsAmbiguous() {
assertThat(reconstruct(input(List.of(flow("f",GOOD,"DO","-1","9","2026-10-15T08:00:00")),List.of(anchor(GOOD,"9")))).get(0).reason()).isEqualTo("ANCHOR_TIME_AMBIGUOUS");
}
@Test void sameSecondOrderUsesBalancesNotIdOrResponseOrder() {
var rows=List.of(flow("1",GOOD,"DO","-1","37","2026-10-15T01:00:00"),flow("9",GOOD,"DO","-1","38","2026-10-15T01:00:00"));
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"37")))).get(0).quantity()).isEqualByComparingTo("39");
}
@Test void brokenChainDoesNotPublish() {
var rows=List.of(flow("1",GOOD,"DO","-1","38","2026-10-15T01:00:00"),flow("2",GOOD,"DO","-1","35","2026-10-15T02:00:00"));
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"35")))).get(0).reason()).isEqualTo("CHAIN_DISCONTINUITY");
}
@Test void laterAnchorRevisionChangesEarlierDay() {
var a=reconstruct(input(List.of(flow("f",GOOD,"DO","-1","100","2026-10-15T01:00:00")),List.of(anchor(GOOD,"100"))));
var b=reconstruct(input(List.of(flow("f",GOOD,"DO","-2","100","2026-10-15T01:00:00")),List.of(anchor(GOOD,"100"))));
assertThat(a.get(0).quantity()).isEqualByComparingTo("101");assertThat(b.get(0).quantity()).isEqualByComparingTo("102");
}
@Test void negativeSkuBalanceCannotHideBehindPositiveBatchClosing() {
assertThat(reconstruct(input(List.of(flow("f",GOOD,"PA","5","5","2026-10-15T01:00:00")),List.of(anchor(GOOD,"3")))).get(0).reason()).isEqualTo("NEGATIVE_RECONSTRUCTED_STOCK");
}
@Test void mergingCoverageDoesNotBridgeGaps() {
var t=Instant.EPOCH;
assertThat(covered(List.of(new Interval(t,t.plusSeconds(2)),new Interval(t.plusSeconds(1),t.plusSeconds(4))),t,t.plusSeconds(4))).isTrue();
assertThat(covered(List.of(new Interval(t,t.plusSeconds(1)),new Interval(t.plusSeconds(2),t.plusSeconds(4))),t,t.plusSeconds(4))).isFalse();
}
}
@@ -0,0 +1,126 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.selfoperated.analytics.client.RestLingxingAnalyticsClient;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.baiyue.lastleg.selfoperated.analytics.sync.SelfopLeaseService;
import com.fasterxml.jackson.databind.JsonNode;
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.junit.jupiter.api.*;
import java.nio.file.*;
import java.time.*;
import java.math.BigDecimal;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** Actual mapper + SQL + transaction pipeline, isolated H2; provider is a bounded fixture. */
public class InventoryHistoryPipelineTest {
JdbcTemplate jdbc;TransactionTemplate tx;SelfopAnalyticsProperties config;SqlSessionTemplate session;
SelfopSyncTaskMapper tasks;SelfopSyncRunMapper runs;SelfopSyncStageMapper stages;SelfopCoordinationMapper locks;
SelfopLeaseService leases;RestLingxingAnalyticsClient client;InventoryHistoryVersions versions;InventoryFlowFacts facts;
InventoryHistoryBuilder builder;InventoryFlowCollector collector;
static final LocalDate DAY=LocalDate.of(2026,10,14);
@BeforeEach void setup()throws Exception {
var ds=new UnpooledDataSource("org.h2.Driver","jdbc:h2:mem:history_pipeline_"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa","");
jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds));
String ddl=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String table:List.of("ll_selfop_outbound_discovery_scope","ll_selfop_sync_scope","ll_selfop_sync_task","ll_selfop_sync_run","ll_selfop_sync_stage","ll_selfop_inventory_snapshot","ll_selfop_sku_daily_sales","ll_selfop_sku_profile","ll_selfop_inventory_flow","ll_selfop_inventory_day_version","ll_selfop_inventory_daily")) {
var m=java.util.regex.Pattern.compile("CREATE TABLE (?:IF NOT EXISTS )?"+table+" \\([\\s\\S]*?\\) ENGINE[^;]+;").matcher(ddl);assertTrue(m.find(),table);
jdbc.execute(m.group().replaceAll("\\) ENGINE[^;]+;",");").replace(" JSON "," VARCHAR(1000000) "));
}
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR '"+getClass().getName()+".extract'");jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR '"+getClass().getName()+".unquote'");
var c=new MybatisConfiguration();c.setMapUnderscoreToCamelCase(true);
for(var mapper:List.of(SelfopSyncTaskMapper.class,SelfopSyncRunMapper.class,SelfopSyncStageMapper.class,SelfopCoordinationMapper.class,SelfopSyncScopeMapper.class,SelfopOutboundDiscoveryScopeMapper.class))c.addMapper(mapper);
var factory=new MybatisSqlSessionFactoryBean();factory.setConfiguration(c);factory.setDataSource(ds);session=new SqlSessionTemplate(Objects.requireNonNull(factory.getObject()));
tasks=session.getMapper(SelfopSyncTaskMapper.class);runs=session.getMapper(SelfopSyncRunMapper.class);stages=session.getMapper(SelfopSyncStageMapper.class);
var real=session.getMapper(SelfopCoordinationMapper.class);locks=mock(SelfopCoordinationMapper.class,org.mockito.AdditionalAnswers.delegatesTo(real));
doReturn(List.of()).when(locks).dueContinuous(any(),anyInt(),anyString());
config=new SelfopAnalyticsProperties();config.setSourceAccount("demo");config.setCollectionMode("BUSINESS");config.setContinuousBusinessCollectionEnabled(true);config.setInventoryHistoryEnabled(true);config.setSourceQueryTimezone("Asia/Shanghai");config.setQuantityUnit("PCS");config.getWarehouses().put("W",new SelfopAnalyticsProperties.Warehouse());
leases=new SelfopLeaseService(locks,session.getMapper(SelfopSyncScopeMapper.class),session.getMapper(SelfopOutboundDiscoveryScopeMapper.class),config);
client=mock(RestLingxingAnalyticsClient.class);versions=new InventoryHistoryVersions(jdbc,tx);facts=new InventoryFlowFacts(jdbc,tx,versions);
collector=newCollector();builder=new InventoryHistoryBuilder(jdbc,tx,versions,tasks,runs,stages);
jdbc.update("INSERT INTO ll_selfop_outbound_discovery_scope (id,created_at,updated_at,source_account,fence_token,request_budget,time_budget_seconds,min_request_interval_ms) VALUES(1,?,?,?,0,100,120,1200)",utcNow(),utcNow(),"demo");
jdbc.update("INSERT INTO ll_selfop_sync_scope (id,created_at,updated_at,source_account,warehouse_code,data_family,scope_json,scope_hash,business_timezone,source_timezone,schedule_cron,stale_after_seconds,fence_token,fact_revision,published_fact_revision,rebuild_required,review_request_budget,review_time_budget_seconds) VALUES(2,?,?,'demo','W','INVENTORY','{}',?,'Asia/Shanghai','Asia/Shanghai','0 0 8 * * *',93600,0,0,0,0,100,120)",utcNow(),utcNow(),"b".repeat(64));
}
@AfterEach void close(){jdbc.execute("SHUTDOWN");}
public static String extract(String value,String path){return json(read(value,JsonNode.class).path(path.substring(2)));}
public static String unquote(String value){return read(value,JsonNode.class).asText();}
InventoryFlowCollector newCollector(){return new InventoryFlowCollector(config,tasks,runs,stages,locks,leases,client,jdbc,tx);}
InventoryFlowSlice slice(){return new InventoryFlowSlice("W",DAY.atStartOfDay(),DAY.plusDays(1).atStartOfDay(),1000);}
JsonNode flow(String id,String delta,String close){return read("{\"id\":\""+id+"\",\"customerCode\":\"C\",\"whCode\":\"W\",\"productSku\":\"SKU\",\"productQuality\":0,\"cellNo\":\"CELL\",\"innerBatchNo\":\"BATCH\",\"billType\":\"DO\",\"bookQty\":"+delta+",\"bookChangQty\":\""+delta+"\",\"closeQty\":"+close+",\"createTime\":\"2026-10-14 12:00:00\"}",JsonNode.class);}
JsonNode page(int current,long total,List<JsonNode> rows){return read(json(Map.of("current",current,"size",1000,"total",total,"pages",total==0?0:(total-1)/1000+1,"records",rows)),JsonNode.class);}
void respond(JsonNode page){when(client.inventoryFlowPage(any(),eq("W"),any(),any(),anyInt(),eq(1000))).thenReturn(page);}
void collect(InventoryFlowSlice slice,LocalDate round,JsonNode page){collector.plan(1,slice,round);respond(page);collector.page();while(facts.mergeNext("demo")){} }
@Test void collectionReplaysDoNotDoubleCountAndRevisionInvalidatesAtomically() {
collect(slice(),DAY,page(1,1,List.of(flow("F","-1","9"))));
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_flow",Integer.class));
assertEquals("FACTS_APPLIED",jdbc.queryForObject("SELECT status FROM ll_selfop_sync_run",String.class));
collect(slice(),DAY.plusDays(1),page(1,1,List.of(flow("F","-1","9"))));
assertEquals(1,jdbc.queryForObject("SELECT revision_no FROM ll_selfop_inventory_flow",Integer.class));
var build=new InventoryHistoryVersions.Build("demo","W","b".repeat(64),50,51,52,null,versions.factHash("demo","W"),"{}",Map.of());
var candidate=versions.create(build,DAY);versions.ready(candidate,0);versions.publish(build,List.of(candidate));
collect(slice(),DAY.plusDays(2),page(1,1,List.of(flow("F","-2","8"))));
assertEquals(2,jdbc.queryForObject("SELECT revision_no FROM ll_selfop_inventory_flow",Integer.class));
assertNotNull(jdbc.queryForObject("SELECT invalidated_at FROM ll_selfop_inventory_day_version",Object.class));
assertEquals(3,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_stage",Integer.class));
}
@Test void changedIdentityIsQuarantinedAndNeverMoved() {
collect(slice(),DAY,page(1,1,List.of(flow("F","-1","9"))));
var changed=(com.fasterxml.jackson.databind.node.ObjectNode)flow("F","-1","9");changed.put("customerCode","OTHER");
collect(slice(),DAY.plusDays(1),page(1,1,List.of(changed)));
assertEquals("C",jdbc.queryForObject("SELECT customer_code FROM ll_selfop_inventory_flow",String.class));
assertEquals("FLOW_IDENTITY_CONFLICT",jdbc.queryForObject("SELECT validation_reason FROM ll_selfop_inventory_flow",String.class));
}
@Test void missingPreviouslySeenFlowRemainsUnknownInsteadOfBeingDeleted() {
collect(slice(),DAY,page(1,1,List.of(flow("F","-1","9"))));
collect(slice(),DAY.plusDays(1),page(1,0,List.of()));
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_flow",Integer.class));
assertEquals("SOURCE_FLOW_MISSING",jdbc.queryForObject("SELECT validation_reason FROM ll_selfop_inventory_flow",String.class));
}
@Test void restartedCollectorStartsNewAttemptAndDoesNotMixPages() {
collector.plan(1,slice(),DAY);var rows=new ArrayList<JsonNode>();for(int i=0;i<1000;i++)rows.add(flow("F"+i,"-1","9"));
respond(page(1,1001,rows));collector.page();assertEquals(1000,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_stage",Integer.class));
collector=newCollector();respond(page(1,1,List.of(flow("NEW","-1","9"))));collector.page();
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_run",Integer.class));
assertEquals("ABORTED",jdbc.queryForObject("SELECT status FROM ll_selfop_sync_run WHERE attempt_no=1",String.class));
while(facts.mergeNext("demo")){}assertEquals("NEW",jdbc.queryForObject("SELECT source_flow_id FROM ll_selfop_inventory_flow",String.class));
}
@Test void malformedPageSplitsFixedWindowWithoutGrantingCoverage() {
collector.plan(1,slice(),DAY);respond(page(1,2,List.of(flow("F","-1","9"))));collector.page();
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE status='DEAD'",Integer.class));
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE status='READY'",Integer.class));
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_run WHERE coverage_status='COMPLETE'",Integer.class));
assertFalse(facts.mergeNext("demo"));
}
@Test void independentAnchorMismatchPublishesUnknownAndNeverInventsQuantity() {
stock(100,DAY,20);stock(101,DAY.plusDays(1),9);
collect(slice(),DAY,page(1,1,List.of(flow("F","-1","9"))));
collect(new InventoryFlowSlice("W",DAY.plusDays(1).atStartOfDay(),DAY.plusDays(1).atTime(8,0,2),1000),DAY,page(1,0,List.of()));
builder.build("demo","W",30);
assertEquals("INDEPENDENT_ANCHOR_MISMATCH",jdbc.queryForObject("SELECT quality_reason FROM ll_selfop_inventory_daily",String.class));
assertNull(jdbc.queryForObject("SELECT physical_quantity FROM ll_selfop_inventory_daily",BigDecimal.class));
}
@Test void localReconstructionPublishesDayAndKeepsRawSnapshotsUnchanged() {
stock(100,DAY,10);stock(101,DAY.plusDays(1),9);
collect(slice(),DAY,page(1,1,List.of(flow("F","-1","9"))));
var tail=new InventoryFlowSlice("W",DAY.plusDays(1).atStartOfDay(),DAY.plusDays(1).atTime(8,0,2),1000);
collect(tail,DAY,page(1,0,List.of()));builder.build("demo","W",30);
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_day_version WHERE head_slot=1",Integer.class));
assertEquals(0,new BigDecimal("9").compareTo(jdbc.queryForObject("SELECT physical_quantity FROM ll_selfop_inventory_daily",BigDecimal.class)));
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_snapshot",Integer.class));
builder.build("demo","W",30);assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_day_version",Integer.class));
}
void stock(long id,LocalDate day,int quantity) {
var now=day.atTime(0,0);var r=new SelfopSyncRun();r.setId(id);r.setTaskId(id);r.setAttemptNo(1);r.setSourceAccount("demo");r.setWarehouseCode("W");r.setScopeId(2L);r.setRunRole("WAREHOUSE_SYNC");r.setExecutionPurpose("BUSINESS");r.setScopeHash("b".repeat(64));r.setScopeSnapshotJson("{}");r.setFenceToken(0L);r.setStatus("PUBLISHED");r.setLastPhase("PUBLISHED");r.setCoverageStatus("COMPLETE");r.setSourceTotalCount(1L);r.setUniqueObjectCount(1L);r.setPageCount(1);r.setCreatedAt(now);r.setUpdatedAt(now);r.setFetchStartedAtUtc(now);r.setFetchFinishedAtUtc(now.plusSeconds(1));r.setPublishedAtUtc(now.plusSeconds(2));runs.insert(r);
jdbc.update("INSERT INTO ll_selfop_inventory_snapshot(id,created_at,updated_at,snapshot_run_id,source_account,customer_code,warehouse_code,sku_code,product_quality,physical_quantity,observed_at_utc,quality_status) VALUES(?,?,?,?,'demo','C','W','SKU',0,?,?,'VALID')",id,now,now,id,quantity,now.plusSeconds(1));
jdbc.update("INSERT INTO ll_selfop_sku_profile(id,created_at,updated_at,profile_run_id,source_account,customer_code,warehouse_code,sku_code,quantity_unit,evidence_type,first_seen_at_utc,last_seen_run_id) VALUES(?,?,?,?,'demo','C','W','SKU','PCS','INVENTORY_OBSERVATION',?,?)",id,now,now,id,now,id);
}
}
@@ -0,0 +1,87 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import org.junit.jupiter.api.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static com.baiyue.lastleg.selfoperated.analytics.history.InventoryHistoryCalculator.*;
import static com.baiyue.lastleg.selfoperated.analytics.history.InventoryHistoryVersions.*;
class InventoryHistoryVersionsTest {
JdbcTemplate jdbc;TransactionTemplate tx;InventoryHistoryVersions versions;
final LocalDate day=LocalDate.of(2026,10,1);
@BeforeEach void setup() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa","");
jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds));versions=new InventoryHistoryVersions(jdbc,tx);
jdbc.execute("CREATE TABLE ll_selfop_sync_scope(id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),data_family VARCHAR(32),scope_hash VARCHAR(64))");
jdbc.update("INSERT INTO ll_selfop_sync_scope VALUES(1,'a','w','INVENTORY','scope')");
String ddl=new ClassPathResource("db/selfop-inventory-history.sql").getContentAsString(StandardCharsets.UTF_8)
.replaceAll("(?m)^--.*$","").replaceAll("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin","");
for(String statement:ddl.split(";")) if(!statement.isBlank())jdbc.execute(statement);
}
Build build(long id,Map<LocalDate,Long> heads) {return new Build("a","w","scope",id,id,10,20L,versions.factHash("a","w"),"{}",heads);}
Candidate candidate(Build b,LocalDate date,String quantity) {
Candidate c=versions.create(b,date);
versions.append(c,List.of(new Daily(date,new Key(new Sku("o","s"),0),new BigDecimal(quantity),"VALID",null,10)),"evidence");
versions.ready(c,1);return c;
}
@Test void unpublishedRowsAreInvisibleAndGroupSwitchesAllDates() {
var b=build(1,Map.of());var first=candidate(b,day,"100");var second=candidate(b,day.plusDays(1),"100");
assertThat(versions.heads("a","w",day,day.plusDays(2))).isEmpty();
versions.publish(b,List.of(first,second));
var old=versions.heads("a","w",day,day.plusDays(2));
var next=build(2,old);var n1=candidate(next,day,"110");var n2=candidate(next,day.plusDays(1),"110");
assertThat(versions.heads("a","w",day,day.plusDays(2))).isEqualTo(old);
versions.publish(next,List.of(n1,n2));
assertThat(versions.heads("a","w",day,day.plusDays(2))).isEqualTo(Map.of(day,n1.id(),day.plusDays(1),n2.id()));
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_daily",Integer.class)).isEqualTo(4);
}
@Test void failureAfterFirstHeadChangeRollsBackWholeGroup() {
var b=build(1,Map.of());var c1=candidate(b,day,"100");var c2=candidate(b,day.plusDays(1),"100");versions.publish(b,List.of(c1,c2));
var old=versions.heads("a","w",day,day.plusDays(2));var next=build(2,old);
var n1=candidate(next,day,"110");var n2=candidate(next,day.plusDays(1),"110");
jdbc.update("UPDATE ll_selfop_inventory_day_version SET invalidated_at=CURRENT_TIMESTAMP WHERE id=?",n2.id());
assertThatThrownBy(()->versions.publish(next,List.of(n1,n2))).hasMessage("HISTORY_CANDIDATE_CHANGED");
assertThat(versions.heads("a","w",day,day.plusDays(2))).isEqualTo(old);
assertThat(jdbc.queryForObject("SELECT status FROM ll_selfop_inventory_day_version WHERE id=?",String.class,n1.id())).isEqualTo("READY");
}
@Test void partialGroupAndIncompleteSkuSetsCannotPublish() {
var b=build(1,Map.of());var c1=candidate(b,day,"100");candidate(b,day.plusDays(1),"100");
assertThatThrownBy(()->versions.publish(b,List.of(c1))).hasMessage("HISTORY_GROUP_INCOMPLETE");
var c=versions.create(build(2,Map.of()),day.plusDays(2));
assertThatThrownBy(()->versions.ready(c,1)).hasMessage("HISTORY_CANDIDATE_INCOMPLETE");
}
@Test void unknownRowsAreRetainedAndPublishedVersionsCannotBeAppended() {
var b=build(1,Map.of());var c=versions.create(b,day);
versions.append(c,List.of(new Daily(day,new Key(new Sku("o","s"),0),null,"UNKNOWN","ANCHOR_MISSING",0)),"hash");versions.ready(c,1);versions.publish(b,List.of(c));
assertThat(jdbc.queryForObject("SELECT unknown_sku_count FROM ll_selfop_inventory_day_version WHERE id=?",Long.class,c.id())).isEqualTo(1);
assertThatThrownBy(()->versions.append(c,List.of(),"hash")).hasMessage("HISTORY_CANDIDATE_IMMUTABLE");
}
@Test void publicationIsIdempotentAfterCommit() {
var b=build(1,Map.of());var c=candidate(b,day,"100");versions.publish(b,List.of(c));versions.publish(b,List.of(c));
assertThat(versions.heads("a","w",day,day.plusDays(1))).hasSize(1);
}
@Test void aNewHeadRejectsStaleBuild() {
var stale=build(1,Map.of());var c=candidate(stale,day,"100");
var winner=build(2,Map.of());var c2=candidate(winner,day,"110");versions.publish(winner,List.of(c2));
assertThatThrownBy(()->versions.publish(stale,List.of(c))).hasMessage("HISTORY_HEAD_CHANGED");
}
@Test void changedWarehouseScopeRejectsFrozenCandidate() {
var b=build(1,Map.of());var c=candidate(b,day,"100");
jdbc.update("UPDATE ll_selfop_sync_scope SET scope_hash='new-scope'");
assertThatThrownBy(()->versions.publish(b,List.of(c))).hasMessage("HISTORY_SCOPE_CHANGED");
assertThat(versions.heads("a","w",day,day.plusDays(1))).isEmpty();
}
@Test void knownInvaliditySurvivesFailedRebuild() {
var b=build(1,Map.of());var c=candidate(b,day,"100");versions.publish(b,List.of(c));
tx.executeWithoutResult(s->{versions.lockWarehouse("a","w");versions.invalidate("a","w",2,"FLOW_REVISION");});
assertThat(jdbc.queryForObject("SELECT invalidation_reason FROM ll_selfop_inventory_day_version WHERE id=?",String.class,c.id())).isEqualTo("FLOW_REVISION");
}
}
@@ -26,7 +26,8 @@ class InventoryTurnoverContextTest {
when(analytics.publishedRun(run.id())).thenReturn(run); when(analytics.publishedRun(run.id())).thenReturn(run);
when(mapper.versionAt(eq(run.scopeId()),eq(run.scopeHash()),any())).thenReturn(run); when(mapper.versionAt(eq(run.scopeId()),eq(run.scopeHash()),any())).thenReturn(run);
} }
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of(new InventoryTurnoverReadMapper.SampleRun(END,100L))); when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of(new InventoryTurnoverReadMapper.DayVersion(END,100L)));
when(mapper.fixedSamples(eq("demo"),eq("W"),eq(IH),any())).thenReturn(List.of(new InventoryTurnoverReadMapper.DayVersion(END,100L)));
when(mapper.accumulationStartedOn(eq("demo"),eq("W"),eq(IH),any())).thenReturn(END); when(mapper.accumulationStartedOn(eq("demo"),eq("W"),eq(IH),any())).thenReturn(END);
} }
@Test void sameContextFreezesAllThirtyDatesAndMissingSamples() { @Test void sameContextFreezesAllThirtyDatesAndMissingSamples() {
@@ -40,10 +41,12 @@ class InventoryTurnoverContextTest {
assertEquals(first,second.response()); assertEquals(first,second.response());
assertEquals(InventoryTurnoverSql.input(request(first.snapshotContext()),contexts.resolve(request(first.snapshotContext()))).days(), assertEquals(InventoryTurnoverSql.input(request(first.snapshotContext()),contexts.resolve(request(first.snapshotContext()))).days(),
InventoryTurnoverSql.input(page2,second).days()); InventoryTurnoverSql.input(page2,second).days());
verify(mapper,times(4)).samples("demo","W",IH,utc(NOW),END.minusDays(29),END.plusDays(1)); verify(mapper,times(1)).samples("demo","W",IH,utc(NOW),END.minusDays(29),END.plusDays(1));
// A subsequently published sample cannot appear in the old asOf query. If reconstruction // Even a late commit bearing an older wall-clock timestamp cannot change the exact mapping.
// ever differs, reject the context instead of mixing old charts and new rows. when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of(new InventoryTurnoverReadMapper.DayVersion(END,101L)));
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of()); assertEquals(first,contexts.resolve(request(first.snapshotContext())).response());
// Explicit invalidation revokes the old evidence rather than silently switching its version.
when(mapper.fixedSamples(eq("demo"),eq("W"),eq(IH),any())).thenReturn(List.of());
assertThrows(BizException.class,()->contexts.resolve(request(first.snapshotContext()))); assertThrows(BizException.class,()->contexts.resolve(request(first.snapshotContext())));
} }
@Test void fullYearContextRemainsSmallAndReconstructsEveryMissingDate() { @Test void fullYearContextRemainsSmallAndReconstructsEveryMissingDate() {
@@ -53,14 +56,12 @@ class InventoryTurnoverContextTest {
assertTrue(first.snapshotContext().length()<2048); assertTrue(first.snapshotContext().length()<2048);
var next=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,20,false,first.snapshotContext(),false); var next=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,20,false,first.snapshotContext(),false);
assertEquals(first,at(NOW.plusSeconds(60)).resolve(next).response()); assertEquals(first,at(NOW.plusSeconds(60)).resolve(next).response());
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of()); when(mapper.fixedSamples(eq("demo"),eq("W"),eq(IH),any())).thenReturn(List.of());
assertThrows(BizException.class,()->contexts.resolve(next)); assertThrows(BizException.class,()->contexts.resolve(next));
} }
@Test void rejectsTamperedVersionsMappingWarehouseAndExpiry() throws Exception { @Test void rejectsTamperedVersionsMappingWarehouseAndExpiry() throws Exception {
String token=contexts.resolve(request(null)).response().snapshotContext(); String token=contexts.resolve(request(null)).response().snapshotContext();
var evidence=json.readTree(Base64.getUrlDecoder().decode(token)); String tampered=(token.charAt(0)=='A'?"B":"A")+token.substring(1);
((com.fasterxml.jackson.databind.node.ObjectNode)evidence).put("salesRun",999L);
String tampered=Base64.getUrlEncoder().encodeToString(json.writeValueAsBytes(evidence));
assertThrows(BizException.class,()->contexts.resolve(request(tampered))); assertThrows(BizException.class,()->contexts.resolve(request(tampered)));
var another=new InventoryTurnoverRequest("demo","OTHER",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token,false); var another=new InventoryTurnoverRequest("demo","OTHER",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token,false);
assertThrows(BizException.class,()->contexts.resolve(another)); assertThrows(BizException.class,()->contexts.resolve(another));
@@ -81,7 +82,7 @@ class InventoryTurnoverContextTest {
} }
@Test void rejectsDuplicateRepresentativesAndScopeDrift() { @Test void rejectsDuplicateRepresentativesAndScopeDrift() {
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of( when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of(
new InventoryTurnoverReadMapper.SampleRun(END,100L),new InventoryTurnoverReadMapper.SampleRun(END,101L))); new InventoryTurnoverReadMapper.DayVersion(END,100L),new InventoryTurnoverReadMapper.DayVersion(END,101L)));
assertThrows(BizException.class,()->contexts.resolve(request(null))); assertThrows(BizException.class,()->contexts.resolve(request(null)));
when(analytics.publishedRun(11L)).thenReturn(run(11,2,IH)); when(analytics.publishedRun(11L)).thenReturn(run(11,2,IH));
assertThrows(BizException.class,()->contexts.resolve(request(null))); assertThrows(BizException.class,()->contexts.resolve(request(null)));
@@ -141,6 +142,17 @@ class InventoryTurnoverContextTest {
var q=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,2,20,false,first.context().snapshotContext(),false); var q=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,2,20,false,first.context().snapshotContext(),false);
var next=service.query(q);assertNull(next.summary());assertNull(next.charts());verify(mapper).report(any());verify(mapper,never()).chartWindows(any()); var next=service.query(q);assertNull(next.summary());assertNull(next.charts());verify(mapper).report(any());verify(mapper,never()).chartWindows(any());
} }
@Test void fullYearContextKeepsExactVersionsAndBoundedSignedToken() {
var versions=java.util.stream.IntStream.range(0,366).mapToObj(i->new InventoryTurnoverReadMapper.DayVersion(END.minusDays(365-i),2110000000000000000L+i*100000003L)).toList();
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(versions);
when(mapper.fixedSamples(eq("demo"),eq("W"),eq(IH),any())).thenReturn(versions);
var first=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,null,1,20,true,null,false);
var frozen=contexts.resolve(first).response();
assertEquals(366,frozen.dailySamples().size());
assertTrue(frozen.snapshotContext().length()<7500);
var next=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,null,2,20,false,frozen.snapshotContext(),false);
assertEquals(frozen,contexts.resolve(next).response());
}
private InventoryTurnoverContext at(Instant instant){return new InventoryTurnoverContext(analytics,mapper,json,Clock.fixed(instant,ZoneOffset.UTC),mock(SelfopOwnerDirectory.class));} private InventoryTurnoverContext at(Instant instant){return new InventoryTurnoverContext(analytics,mapper,json,Clock.fixed(instant,ZoneOffset.UTC),mock(SelfopOwnerDirectory.class));}
static InventoryTurnoverRequest request(String token){return new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token,false);} static InventoryTurnoverRequest request(String token){return new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token,false);}
private static LocalDateTime utc(Instant instant){return LocalDateTime.ofInstant(instant,ZoneOffset.UTC);} private static LocalDateTime utc(Instant instant){return LocalDateTime.ofInstant(instant,ZoneOffset.UTC);}
@@ -24,6 +24,8 @@ class InventoryTurnoverSqlTest {
execute("CREATE TABLE ll_selfop_sku_profile(profile_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,product_name VARCHAR,quantity_unit VARCHAR,evidence_type VARCHAR,evidence_run_id BIGINT,earliest_evidence_at_utc TIMESTAMP)"); execute("CREATE TABLE ll_selfop_sku_profile(profile_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,product_name VARCHAR,quantity_unit VARCHAR,evidence_type VARCHAR,evidence_run_id BIGINT,earliest_evidence_at_utc TIMESTAMP)");
execute("CREATE TABLE ll_selfop_sku_daily_sales(sales_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,business_date DATE,outbound_quantity DECIMAL(20,6),quantity_unit VARCHAR)"); execute("CREATE TABLE ll_selfop_sku_daily_sales(sales_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,business_date DATE,outbound_quantity DECIMAL(20,6),quantity_unit VARCHAR)");
execute("CREATE TABLE ll_selfop_inventory_snapshot(snapshot_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,product_quality INT,physical_quantity DECIMAL(20,6),available_quantity DECIMAL(20,6),frozen_quantity DECIMAL(20,6),transit_quantity DECIMAL(20,6),quality_status VARCHAR,quality_reason VARCHAR)"); execute("CREATE TABLE ll_selfop_inventory_snapshot(snapshot_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,product_quality INT,physical_quantity DECIMAL(20,6),available_quantity DECIMAL(20,6),frozen_quantity DECIMAL(20,6),transit_quantity DECIMAL(20,6),quality_status VARCHAR,quality_reason VARCHAR)");
execute("CREATE TABLE ll_selfop_inventory_day_version(id BIGINT PRIMARY KEY,source_account VARCHAR,warehouse_code VARCHAR,rule_version VARCHAR,invalidated_at TIMESTAMP)");
execute("CREATE TABLE ll_selfop_inventory_daily(day_version_id BIGINT,customer_code VARCHAR,sku_code VARCHAR,product_quality INT,physical_quantity DECIMAL(20,6),quality_status VARCHAR,quality_reason VARCHAR)");
} }
@AfterEach void close() throws Exception {db.close();} @AfterEach void close() throws Exception {db.close();}
private void master() throws Exception { private void master() throws Exception {
@@ -285,7 +287,7 @@ class InventoryTurnoverSqlTest {
} }
@Test void invalidHistoricalUnitOnlyInvalidatesItsSampleAndNeverActualOwnerSales() throws Exception { @Test void invalidHistoricalUnitOnlyInvalidatesItsSampleAndNeverActualOwnerSales() throws Exception {
seed("A","SKU",30); seed("A","SKU",30);
execute("UPDATE ll_selfop_sku_profile SET quantity_unit='UNKNOWN' WHERE profile_run_id=100"); execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=NULL,quality_status='UNKNOWN',quality_reason='UNIT_UNKNOWN' WHERE day_version_id=100");
var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true); var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true);
var row=query("page",p).get(0); var row=query("page",p).get(0);
decimal("300",row.get("w30_outbound"));decimal("60",row.get("coverage_days")); decimal("300",row.get("w30_outbound"));decimal("60",row.get("coverage_days"));
@@ -301,7 +303,7 @@ class InventoryTurnoverSqlTest {
var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true); var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true);
var row=query("page",p).get(0); var row=query("page",p).get(0);
decimal("0",row.get("turnover"));decimal("1",row.get("no_outbound"));assertNull(row.get("coverage_days")); decimal("0",row.get("turnover"));decimal("1",row.get("no_outbound"));assertNull(row.get("coverage_days"));
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=0 WHERE snapshot_run_id>=100"); execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=0 WHERE day_version_id>=100");
row=query("page",p).get(0);decimal("0",row.get("average_physical"));assertNull(row.get("turnover")); row=query("page",p).get(0);decimal("0",row.get("average_physical"));assertNull(row.get("turnover"));
} }
@Test void emptyWarehouseChartDistinguishesCoveredZeroFromMissingEvidence() throws Exception { @Test void emptyWarehouseChartDistinguishesCoveredZeroFromMissingEvidence() throws Exception {
@@ -409,7 +411,7 @@ class InventoryTurnoverSqlTest {
@Test void sparseSamplesKeepExactDateComplementAndFullOwnerDenominator() throws Exception { @Test void sparseSamplesKeepExactDateComplementAndFullOwnerDenominator() throws Exception {
seed("A","ONE",30);seed("A","TWO",30); seed("A","ONE",30);seed("A","TWO",30);
// Both edge bits and a middle bit, with different missing members per day. // Both edge bits and a middle bit, with different missing members per day.
execute("DELETE FROM ll_selfop_inventory_snapshot WHERE warehouse_code='W' AND ((sku_code='ONE' AND snapshot_run_id IN (100,114)) OR (sku_code='TWO' AND snapshot_run_id=129))"); execute("DELETE FROM ll_selfop_inventory_daily WHERE ((sku_code='ONE' AND day_version_id IN (100,114)) OR (sku_code='TWO' AND day_version_id=129))");
for(int n:List.of(7,15,30)) { for(int n:List.of(7,15,30)) {
var q=new InventoryTurnoverRequest("demo","W",END,n,"OWNER",null,null,"ALL",null,null,null,1,20,true,null,false); var q=new InventoryTurnoverRequest("demo","W",END,n,"OWNER",null,null,"ALL",null,null,null,1,20,true,null,false);
var p=input(q,30,30,true); var p=input(q,30,30,true);
@@ -434,7 +436,8 @@ class InventoryTurnoverSqlTest {
long first=9_007_199_254_740_993L; long first=9_007_199_254_740_993L;
for(int i=0;i<30;i++) { for(int i=0;i<30;i++) {
execute("UPDATE ll_selfop_sku_profile SET profile_run_id="+(first+i)+" WHERE profile_run_id="+(100+i)); execute("UPDATE ll_selfop_sku_profile SET profile_run_id="+(first+i)+" WHERE profile_run_id="+(100+i));
execute("UPDATE ll_selfop_inventory_snapshot SET snapshot_run_id="+(first+i)+",physical_quantity=CASE WHEN product_quality=0 THEN "+((i+1)*10)+" ELSE 0 END WHERE snapshot_run_id="+(100+i)); execute("UPDATE ll_selfop_inventory_day_version SET id="+(first+i)+" WHERE id="+(100+i));
execute("UPDATE ll_selfop_inventory_daily SET day_version_id="+(first+i)+",physical_quantity=CASE WHEN product_quality=0 THEN "+((i+1)*10)+" ELSE 0 END WHERE day_version_id="+(100+i));
} }
List<InventoryTurnoverSql.Day> days=new ArrayList<>(); List<InventoryTurnoverSql.Day> days=new ArrayList<>();
for(int i=0;i<30;i++){var date=END.minusDays(29-i);days.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),1,first+i));} for(int i=0;i<30;i++){var date=END.minusDays(29-i);days.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),1,first+i));}
@@ -475,7 +478,7 @@ class InventoryTurnoverSqlTest {
for(String group:List.of("SKU","OWNER")) { for(String group:List.of("SKU","OWNER")) {
var p=input(request(group,"ALL","PHYSICAL_DESC",null,null,1,20),0,30,true); var p=input(request(group,"ALL","PHYSICAL_DESC",null,null,1,20),0,30,true);
var unknown=reportRows(p).get(0);assertNull(unknown.get("w30_outbound"));assertNull(unknown.get("w30_observed")); var unknown=reportRows(p).get(0);assertNull(unknown.get("w30_outbound"));assertNull(unknown.get("w30_observed"));
var partialDays=p.days().stream().map(d->new InventoryTurnoverSql.Day(d.date(),d.startUtc(),2,d.sampleRun())).toList(); var partialDays=p.days().stream().map(d->new InventoryTurnoverSql.Day(d.date(),d.startUtc(),2,d.historyVersionId())).toList();
var partial=reportRows(new InventoryTurnoverSql.Input(p.query(),11,22,true,true,partialDays,LocalDateTime.of(2026,10,13,0,0))).get(0); var partial=reportRows(new InventoryTurnoverSql.Input(p.query(),11,22,true,true,partialDays,LocalDateTime.of(2026,10,13,0,0))).get(0);
assertNull(partial.get("w30_outbound"));decimal("0",partial.get("w30_observed"));decimal("30",partial.get("w30_partial")); assertNull(partial.get("w30_outbound"));decimal("0",partial.get("w30_observed"));decimal("30",partial.get("w30_partial"));
var complete=reportRows(input(p.query(),30,30,true)).get(0); var complete=reportRows(input(p.query(),30,30,true)).get(0);
@@ -541,16 +544,16 @@ class InventoryTurnoverSqlTest {
} }
@Test void presentInvalidSamplesNeverQualifyAndOwnerZeroNeedsEverySku() throws Exception { @Test void presentInvalidSamplesNeverQualifyAndOwnerZeroNeedsEverySku() throws Exception {
seed("A","ONE",30);seed("A","TWO",30); seed("A","ONE",30);seed("A","TWO",30);
String key=" WHERE snapshot_run_id=100 AND sku_code='TWO' AND product_quality=0"; String key=" WHERE day_version_id=100 AND sku_code='TWO' AND product_quality=0";
for(String mutation:List.of("physical_quantity=NULL","physical_quantity=-1","product_quality=9","quality_status='UNKNOWN',quality_reason='UNCONFIRMED'","profile")) { for(String mutation:List.of("physical_quantity=NULL","physical_quantity=-1","product_quality=9","quality_status='UNKNOWN',quality_reason='UNCONFIRMED'")) {
if("profile".equals(mutation))execute("UPDATE ll_selfop_sku_profile SET quantity_unit='UNKNOWN' WHERE profile_run_id=100 AND sku_code='TWO'"); if("profile".equals(mutation))execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=NULL,quality_status='UNKNOWN',quality_reason='UNIT_UNKNOWN' WHERE day_version_id=100 AND sku_code='TWO'");
else execute("UPDATE ll_selfop_inventory_snapshot SET "+mutation+key); else execute("UPDATE ll_selfop_inventory_daily SET "+mutation+key);
var owner=reportRows(input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)).get(0); var owner=reportRows(input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)).get(0);
decimal("29",owner.get("sample_count"));assertNull(owner.get("turnover"));assertEquals(END.minusDays(29).toString(),owner.get("missing_dates")); decimal("29",owner.get("sample_count"));assertNull(owner.get("turnover"));assertEquals(END.minusDays(29).toString(),owner.get("missing_dates"));
execute("DELETE FROM ll_selfop_inventory_snapshot WHERE snapshot_run_id=100 AND sku_code='TWO'");stock(100,"A","TWO",0,450,450);stock(100,"A","TWO",1,50,0); execute("DELETE FROM ll_selfop_inventory_daily WHERE day_version_id=100 AND sku_code='TWO'");stock(100,"A","TWO",0,450,450);stock(100,"A","TWO",1,50,0);
execute("UPDATE ll_selfop_sku_profile SET quantity_unit='PCS' WHERE profile_run_id=100 AND sku_code='TWO'"); execute("UPDATE ll_selfop_sku_profile SET quantity_unit='PCS' WHERE profile_run_id=100 AND sku_code='TWO'");
} }
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=0 WHERE snapshot_run_id>=100"); execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=0 WHERE day_version_id>=100");
var owner=reportRows(input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)).get(0); var owner=reportRows(input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)).get(0);
decimal("30",owner.get("sample_count"));decimal("0",owner.get("average_physical"));assertNull(owner.get("turnover")); decimal("30",owner.get("sample_count"));decimal("0",owner.get("average_physical"));assertNull(owner.get("turnover"));
} }
@@ -568,6 +571,7 @@ class InventoryTurnoverSqlTest {
String amount="99999999999999.123456";var quantity=new BigDecimal(amount); String amount="99999999999999.123456";var quantity=new BigDecimal(amount);
execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity="+amount+" WHERE warehouse_code='W'"); execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity="+amount+" WHERE warehouse_code='W'");
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE WHEN product_quality=0 THEN "+amount+" ELSE 0 END"); execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE WHEN product_quality=0 THEN "+amount+" ELSE 0 END");
execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=CASE WHEN product_quality=0 THEN "+amount+" ELSE 0 END");
var report=query("report",input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)); var report=query("report",input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true));
var row=payload(report.stream().filter(r->"ROW".equals(r.get("section"))).findFirst().orElseThrow()); var row=payload(report.stream().filter(r->"ROW".equals(r.get("section"))).findFirst().orElseThrow());
decimal(quantity.multiply(BigDecimal.valueOf(120)).toPlainString(),row.path("w30_outbound").decimalValue()); decimal(quantity.multiply(BigDecimal.valueOf(120)).toPlainString(),row.path("w30_outbound").decimalValue());
@@ -622,7 +626,7 @@ class InventoryTurnoverSqlTest {
} }
@Test void tinyQualifiedDailySumUsesOneDivisionForSkuAndOwnerThroughProductionCtes() throws Exception { @Test void tinyQualifiedDailySumUsesOneDivisionForSkuAndOwnerThroughProductionCtes() throws Exception {
seed("A","ONE",30);seed("A","TWO",30); seed("A","ONE",30);seed("A","TWO",30);
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE WHEN snapshot_run_id=100 AND product_quality=0 THEN CASE WHEN sku_code='ONE' THEN 0.000001 ELSE 0.000002 END ELSE 0 END WHERE snapshot_run_id>=100"); execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=CASE WHEN day_version_id=100 AND product_quality=0 THEN CASE WHEN sku_code='ONE' THEN 0.000001 ELSE 0.000002 END ELSE 0 END WHERE day_version_id>=100");
execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE WHEN business_date=DATE '"+END+"' THEN CASE WHEN sku_code='ONE' THEN 1 ELSE 2 END ELSE 0 END WHERE warehouse_code='W'"); execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE WHEN business_date=DATE '"+END+"' THEN CASE WHEN sku_code='ONE' THEN 1 ELSE 2 END ELSE 0 END WHERE warehouse_code='W'");
for(String group:List.of("SKU","OWNER")) { for(String group:List.of("SKU","OWNER")) {
var q=request(group,"ALL","TURNOVER_DESC",null,null,1,20);var p=input(q,30,30,true); var q=request(group,"ALL","TURNOVER_DESC",null,null,1,20);var p=input(q,30,30,true);
@@ -636,7 +640,7 @@ class InventoryTurnoverSqlTest {
} }
for(var row:query("page",p))decimal("30000000",row.get("turnover")); for(var row:query("page",p))decimal("30000000",row.get("turnover"));
} }
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=0 WHERE snapshot_run_id>=100"); execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=0 WHERE day_version_id>=100");
for(String group:List.of("SKU","OWNER")) { for(String group:List.of("SKU","OWNER")) {
var q=request(group,"ALL","TURNOVER_DESC",null,null,1,20); var q=request(group,"ALL","TURNOVER_DESC",null,null,1,20);
for(var row:reportRows(input(q,30,30,true))) { for(var row:reportRows(input(q,30,30,true))) {
@@ -668,7 +672,7 @@ class InventoryTurnoverSqlTest {
} }
@Test void turnoverOrderingUsesUnroundedRatioBeforeStableSkuAndOwnerPages() throws Exception { @Test void turnoverOrderingUsesUnroundedRatioBeforeStableSkuAndOwnerPages() throws Exception {
seed("Z","HIGH",30);seed("A","TIE",30);seed("B","TIE",30);seed("L","LOW",30); seed("Z","HIGH",30);seed("A","TIE",30);seed("B","TIE",30);seed("L","LOW",30);
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE WHEN product_quality=0 THEN 10 ELSE 0 END WHERE snapshot_run_id>=100"); execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=CASE WHEN product_quality=0 THEN 10 ELSE 0 END WHERE day_version_id>=100");
execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE WHEN business_date=DATE '"+END+"' THEN CASE sku_code WHEN 'HIGH' THEN 10.000001 WHEN 'LOW' THEN 9.999999 ELSE 10 END ELSE 0 END WHERE warehouse_code='W'"); execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE WHEN business_date=DATE '"+END+"' THEN CASE sku_code WHEN 'HIGH' THEN 10.000001 WHEN 'LOW' THEN 9.999999 ELSE 10 END ELSE 0 END WHERE warehouse_code='W'");
for(String group:List.of("SKU","OWNER")) for(int page=1;page<=4;page++) { for(String group:List.of("SKU","OWNER")) for(int page=1;page<=4;page++) {
var q=request(group,"ALL","TURNOVER_DESC",null,null,page,1); var q=request(group,"ALL","TURNOVER_DESC",null,null,page,1);
@@ -687,6 +691,7 @@ class InventoryTurnoverSqlTest {
void numericSortsUseFormalValuesInBothDirectionsAndKeepUnknownLast(String metric) throws Exception { void numericSortsUseFormalValuesInBothDirectionsAndKeepUnknownLast(String metric) throws Exception {
seed("L","SKU",30);seed("H","SKU",30);seed("U","SKU",30); seed("L","SKU",30);seed("H","SKU",30);seed("U","SKU",30);
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END,available_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END,transit_quantity=CASE customer_code WHEN 'L' THEN 2 WHEN 'H' THEN 8 ELSE NULL END,frozen_quantity=CASE customer_code WHEN 'L' THEN 1 WHEN 'H' THEN 9 ELSE NULL END"); execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END,available_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END,transit_quantity=CASE customer_code WHEN 'L' THEN 2 WHEN 'H' THEN 8 ELSE NULL END,frozen_quantity=CASE customer_code WHEN 'L' THEN 1 WHEN 'H' THEN 9 ELSE NULL END");
execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END");
execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE customer_code WHEN 'L' THEN 1 WHEN 'H' THEN 5 ELSE 9999 END,quantity_unit=CASE customer_code WHEN 'U' THEN 'UNKNOWN' ELSE 'PCS' END"); execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE customer_code WHEN 'L' THEN 1 WHEN 'H' THEN 5 ELSE 9999 END,quantity_unit=CASE customer_code WHEN 'U' THEN 'UNKNOWN' ELSE 'PCS' END");
for(String group:List.of("SKU","OWNER")) { for(String group:List.of("SKU","OWNER")) {
if(group.equals("OWNER")&&metric.equals("COVERAGE")) continue; if(group.equals("OWNER")&&metric.equals("COVERAGE")) continue;
@@ -742,6 +747,10 @@ class InventoryTurnoverSqlTest {
execute("INSERT INTO ll_selfop_sku_profile VALUES("+run+",'demo','W','"+owner+"','"+sku+"','Fixture','PCS','INVENTORY_OBSERVATION',1,TIMESTAMP '2026-01-01 00:00:00')"); execute("INSERT INTO ll_selfop_sku_profile VALUES("+run+",'demo','W','"+owner+"','"+sku+"','Fixture','PCS','INVENTORY_OBSERVATION',1,TIMESTAMP '2026-01-01 00:00:00')");
} }
private void stock(long run,String owner,String sku,int quality,int physical,int available) throws Exception { private void stock(long run,String owner,String sku,int quality,int physical,int available) throws Exception {
if(run>=100) {
execute("MERGE INTO ll_selfop_inventory_day_version KEY(id) VALUES("+run+",'demo','W','FLOW_EOD_V1',NULL)");
execute("INSERT INTO ll_selfop_inventory_daily VALUES("+run+",'"+owner+"','"+sku+"',"+quality+","+physical+",'VALID',NULL)");return;
}
execute("INSERT INTO ll_selfop_inventory_snapshot VALUES("+run+",'demo','W','"+owner+"','"+sku+"',"+quality+","+physical+","+available+",0,0,'VALID',NULL)"); execute("INSERT INTO ll_selfop_inventory_snapshot VALUES("+run+",'demo','W','"+owner+"','"+sku+"',"+quality+","+physical+","+available+",0,0,'VALID',NULL)");
} }
private void execute(String sql) throws Exception {try(var s=db.createStatement()){s.execute(sql);}} private void execute(String sql) throws Exception {try(var s=db.createStatement()){s.execute(sql);}}
@@ -29,6 +29,25 @@ class SelfopEgressServiceTest {
private void success(EgressContext c,Operation op){service.authorize(new AuthorizationRequest(c,op,PROVIDER));service.complete(new CompletionRequest(c,op,PROVIDER,1,null,200,200,false,null));clock.advance(5);} private void success(EgressContext c,Operation op){service.authorize(new AuthorizationRequest(c,op,PROVIDER));service.complete(new CompletionRequest(c,op,PROVIDER,1,null,200,200,false,null));clock.advance(5);}
private SelfopApiBudget budget(){return SelfopSupport.read(account.getApiBudgetJson(),SelfopApiBudget.class);} private SelfopApiBudget budget(){return SelfopSupport.read(account.getApiBudgetJson(),SelfopApiBudget.class);}
private SelfopValidationBudget rootBudget(){return SelfopSupport.read(task.getValidationBudgetJson(),SelfopValidationBudget.class);} private SelfopValidationBudget rootBudget(){return SelfopSupport.read(task.getValidationBudgetJson(),SelfopValidationBudget.class);}
@Test void historyPagesRequireExactFrozenSliceCheckpointAndEnabledWorker(){
continuousBusinessRoot();cfg.setInventoryHistoryEnabled(true);
var slice=new com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice("A",now().minusDays(1).withNano(0),now().withNano(0),1000);
task.setTaskType(com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice.TYPE);
task.setScopeHash(SelfopSupport.hash(slice));task.setRequestHash(task.getScopeHash());task.setQueryScopeJson(SelfopSupport.json(slice));
task.setPlannedAtUtc(now().toLocalDate().atStartOfDay());task.setQueryFromUtc(LocalDateTime.ofInstant(slice.from(),ZoneOffset.UTC));task.setQueryUntilUtc(LocalDateTime.ofInstant(slice.until(),ZoneOffset.UTC));
task.setRequestKey("continuous:history:"+task.getPlannedAtUtc().toLocalDate()+":"+task.getScopeHash());
run.setProgressSnapshotJson("{\"nextPage\":1}");
assertThat(SelfopCollectionPolicy.continuousBusinessRoot(task)).isTrue();
assertThatThrownBy(()->service.reserve(context(),Operation.INVENTORY_FLOW,1000,"B",null,slice.requestHash(1))).hasMessage("EGRESS_HISTORY_INVALID");
assertThatThrownBy(()->service.reserve(context(),Operation.INVENTORY_FLOW,1000,"A",null,slice.requestHash(2))).hasMessage("EGRESS_HISTORY_INVALID");
cfg.setInventoryHistoryEnabled(false);
assertThatThrownBy(()->service.reserve(context(),Operation.INVENTORY_FLOW,1000,"A",null,slice.requestHash(1))).hasMessage("EGRESS_HISTORY_INVALID");
cfg.setInventoryHistoryEnabled(true);
var first=service.reserve(context(),Operation.INVENTORY_FLOW,1000,"A",null,slice.requestHash(1));success(first,Operation.INVENTORY_FLOW);
run.setProgressSnapshotJson("{\"nextPage\":2}");
var second=service.reserve(context(),Operation.INVENTORY_FLOW,1000,"A",null,slice.requestHash(2));success(second,Operation.INVENTORY_FLOW);
assertThat(businessBudget().http).isEqualTo(2);assertThat(budget().confirmedHttp).isEqualTo(2);
}
@Test void masterDataOperationsUseBoundBusinessTaskAndExistingSingleFlightGate(){ @Test void masterDataOperationsUseBoundBusinessTaskAndExistingSingleFlightGate(){
continuousBusinessRoot();task.setTaskType("MASTER_DATA");task.setQueryFromUtc(null);task.setQueryUntilUtc(null); continuousBusinessRoot();task.setTaskType("MASTER_DATA");task.setQueryFromUtc(null);task.setQueryUntilUtc(null);
task.setPlannedAtUtc(now().plusHours(8).toLocalDate().atStartOfDay().minusHours(8)); task.setPlannedAtUtc(now().plusHours(8).toLocalDate().atStartOfDay().minusHours(8));