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-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/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。
@@ -37,7 +39,7 @@
| 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 199 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 202 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
+86 -1
View File
@@ -4002,7 +4002,7 @@ CREATE TABLE ll_selfop_sync_task (
observation_baseline BIGINT NULL COMMENT '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测',
request_key VARCHAR(128) 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 '计划时点;人工请求可空',
query_from_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='自营领星商品资料版本';
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)));
}
@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 = "读取单仓全部货主、全部品质且保留零库存的产品库存单页")
@PostMapping("/product-inventory/query")
public ApiResponse<QueryPage<ProductStock>> inventory(
@@ -23,9 +23,12 @@ public final class LingxingWmsDtos {
public record InventoryFlowRequest(@NotBlank @Size(max=64) String warehouseCode,
@NotBlank @Size(max=64) String customerCode, @NotBlank @Size(max=128) String sku,
@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,
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 Product(String customerCode, String sku, String productName,
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("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,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))
||!request.customerCode().equals(requiredText(row,"customerCode",64))
||!request.sku().equals(requiredText(row,"productSku",128)))throw invalidResponse("FLOW_IDENTITY_MISMATCH",200);
JsonNode id=row.get("id");
if(id==null||!id.isIntegralNumber())throw invalidResponse("FLOW_ID_MISSING",200);
return new InventoryFlow(id.asText(),request.customerCode(),request.warehouseCode(),request.sku(),
String id=requiredText(row,"id",64);
return new InventoryFlow(id,request.customerCode(),request.warehouseCode(),request.sku(),
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,"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) {
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);
if (exactDeliveryNo != null && !exactDeliveryNo.equals(deliveryNo)) throw invalidResponse("LOCATE_DELIVERY_NO_MISMATCH", 200);
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,
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");
if (records == null || !records.isArray()) throw invalidResponse("RECORDS_TYPE_" + nodeType(records), 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 pages = requiredInt(data.get("pages"), "PAGES");
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();
if (sourceSize != size && !emptyException) throw invalidResponse("SIZE_MISMATCH", 200);
long expectedPages = total / size + (total % size == 0 ? 0 : 1);
@@ -51,6 +51,34 @@ x-lastleg-egress-coordination:
security:
- InternalServiceToken: []
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:
post:
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
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.
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:
- {$ref: '#/components/parameters/LingxingEgressContext'}
requestBody:
@@ -76,7 +107,7 @@ paths:
endTime: {type: string, format: date-time, description: Beijing local datetime without offset}
responses:
'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:
post:
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. */
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() {
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());
@@ -315,8 +328,8 @@ class LingxingWmsClientTest {
LocalDateTime.of(2026,9,14,8,0),LocalDateTime.of(2026,10,14,8,0));}
@Test void flowUsesExactScopeSinglePageAndPreservesSignedChangeAndLargeId() throws Exception {
response.set(page("current","size",1,1000,1,1,"""
{"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"}
{"id":"9223372036854775806","customerCode":"C-DEMO","whCode":"W-DEMO","productSku":"SKU-DEMO",
"productQuality":0,"bookQty":-3,"bookChangQty":"-3","closeQty":17,"createTime":"2026-10-13 12:00:00"}
"""));
var result=client().queryInventoryFlow(flow(),context());
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("size").asInt()).isEqualTo(1000);assertThat(sent.has("productQuality")).isFalse();
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).bookChangQty()).isEqualTo("-3");assertThat(calls.get()).isEqualTo(1);
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() {
var c=client();response.set("{\"code\":11008,\"msg\":\"private\"}");
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)));
}
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) {
List<SelfopPayloads.Item> items = null;
JsonNode products = n.get("productList");
@@ -168,10 +174,10 @@ public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient {
case "/product-inventory/query" -> Operation.PRODUCT_INVENTORY;
case "/customers/query" -> Operation.CUSTOMERS;
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");
};
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 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));
@@ -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);
@Select("SELECT * FROM ll_selfop_sync_run WHERE id=#{id} FOR UPDATE")
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);
// 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.
@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);
@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);
@@ -15,6 +15,9 @@ public class SelfopAnalyticsProperties {
private String collectionMode = "OFF";
/** Explicit account-owner continuous BUSINESS planner/egress mode; off by default. */
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 exclusiveGrantId;
private LocalDateTime exclusiveGrantExpiresAtUtc;
@@ -37,6 +37,17 @@ public final class SelfopCollectionPolicy {
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;
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(!"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;
@@ -9,11 +9,13 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
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
public class InventoryTurnoverContext {
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 InventoryTurnoverReadMapper mapper;
private final ObjectMapper json;
@@ -28,7 +30,7 @@ public class InventoryTurnoverContext {
}
public record Evidence(String account,String warehouse,LocalDate end,int days,Instant asOf,
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) {}
Frozen resolve(InventoryTurnoverRequest q) {
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();
LocalDate end=q.end(asOf.atZone(ZONE).toLocalDate());
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 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,
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.
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);
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))) {
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)
||mapping.put(sample.sampleDate(),sample.runId())!=null) throw refresh();
List<InventoryTurnoverReadMapper.DayVersion> selected;
if(supplied==null)selected=mapper.samples(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf),end.minusDays(Math.max(30,q.days())-1),end.plusDays(1));
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<>();
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 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();
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) {
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));
}
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) {
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) {
// Reconstruct the entire ordered calendar at asOf before comparing its digest.
// 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,"defective")!=null&&decimal(r,"defective").signum()>0)attention.add("DEFECTIVE_STOCK");
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");
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,
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"),
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
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} "
+"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")
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 SAMPLE_WHERE=" r.source_account=#{account} AND r.warehouse_code=#{warehouse} AND r.scope_hash=#{scopeHash,jdbcType=VARCHAR} "
+"AND r.inventory_sample_rule_version='DAILY_SCHEDULED_FIRST_V1' AND r.status='PUBLISHED' "
+"AND r.execution_purpose='BUSINESS' AND r.run_role='WAREHOUSE_SYNC' AND r.coverage_status='COMPLETE' "
+"AND r.published_at_utc<=#{asOf} AND t.execution_purpose='BUSINESS' AND t.task_type='SCHEDULED' "
+"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,
String HISTORY_WHERE=" source_account=#{account} AND warehouse_code=#{warehouse} AND scope_hash=#{scopeHash,jdbcType=VARCHAR} "
+"AND rule_version='FLOW_EOD_V1' AND status IN ('PUBLISHED','SUPERSEDED') AND invalidated_at IS NULL ";
@Select("SELECT business_date sample_date,id day_version_id FROM ll_selfop_inventory_day_version WHERE "+HISTORY_WHERE
+"AND head_slot=1 AND published_at<=#{asOf} AND business_date>=#{from} AND business_date<#{until} ORDER BY business_date")
List<DayVersion> 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);
@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);
@SelectProvider(type=InventoryTurnoverSql.class,method="page")
@@ -23,7 +23,7 @@ public final class InventoryTurnoverResponses {
List<LocalDate> missingSampleDates,List<String> sampleReasonCodes,LocalDate lastObservedOutboundDate,
List<String> attentionReasons,Risk noOutboundInWindow,Risk slowCoverage,int unassessableSkuCount,
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,
int baseWindowDays,Instant queryAsOf,Instant queryExpiresAt,AnalyticsResponses.Context analyticsContext,
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. */
public final class 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,
boolean salesKnown,List<Day> days,LocalDateTime queryAsOf) {
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());
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),
!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(),
"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) {
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,"
+"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);
}
private static String sampleRuns(Input p) {
private static String historyVersions(Input p) {
// The frozen context already resolved these versions. Bind them directly instead
// of repeatedly expanding the 30-day calendar as a membership subquery.
var runs=new ArrayList<String>();
for(int i=0;i<p.days().size();i++) if(p.days().get(i).sampleRun()!=null)
runs.add("#{p.days["+i+"].sampleRun,jdbcType=BIGINT}");
for(int i=0;i<p.days().size();i++) if(p.days().get(i).historyVersionId()!=null)
runs.add("#{p.days["+i+"].historyVersionId,jdbcType=BIGINT}");
return runs.isEmpty()?"NULL":String.join(",",runs);
}
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,"
+"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 "
+"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";
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)+"))";
StringBuilder sql=new StringBuilder("WITH calendar AS (").append(calendar(p)).append("), fixed_profiles AS (").append(profiles)
String profiles="SELECT * FROM ll_selfop_sku_profile WHERE "+scope(p,"")+" AND profile_run_id IN (#{p.salesRun},#{p.inventoryRun})";
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("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("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("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 ")
@@ -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 ")
.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 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 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")
@@ -127,9 +128,8 @@ public final class InventoryTurnoverSql {
// Read only actual qualified samples at the frozen date/run. Missing candidates
// 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,")
.append("st.physical sample_physical FROM calendar d JOIN stocks st ON st.snapshot_run_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 ip.quantity_unit='PCS' AND st.physical IS NOT NULL), sku_sample_counts AS (SELECT customer_code,sku_code,")
.append("st.physical sample_physical FROM calendar d JOIN history st ON st.day_version_id=d.sample_run ")
.append("WHERE 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,")
// 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,")
@@ -146,11 +146,11 @@ public final class InventoryTurnoverSql {
// 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 ',')";
}
private static String pageMissingDates() {
private static String pageMissingDates(Input p) {
// Only the selected page expands dates. Numeric qualification and filtering
// 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 "
+"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) {
StringBuilder sql=new StringBuilder();
@@ -243,7 +243,7 @@ public final class InventoryTurnoverSql {
}
public static String page(Map<String,Object> args) {
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) {
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) {
Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()),overview=p.query.overview();
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);
fields.addAll(InventoryVolumeSql.fields(p));
if(owner) {
@@ -281,7 +281,7 @@ public final class InventoryTurnoverSql {
// 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) ":"")
+", 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)"
+", 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;
@@ -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);}}
verify(task,run,root,account,now);
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())
||pageSize!=1000||!config.getWarehouses().containsKey(warehouseCode)
||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 root=root(parseId(c.rootTaskId()),c.executionPurpose(),c.sourceAccount());
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)){
var b=business(root);verifyBusinessTask(task,root);
// 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.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 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)
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))
@@ -32,10 +33,11 @@ public interface SalesRetentionMapper {
List<SelfopSyncRun> eligible(@Param("scopeId") Long scopeId,@Param("current") Long current,
@Param("previous") Long previous,@Param("cutoff") LocalDateTime cutoff);
@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}
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);
@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 '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测',
request_key VARCHAR(128) 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 '计划时点;人工请求可空',
query_from_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),
KEY idx_selfop_product_lookup (source_account,customer_code,sku_code,run_id)
) 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权限。
仓库由服务端受控配置校验;不触发采集,不读取来源订单,不调用领星。
所选窗口为截至endDate的1~366个完整北京时间自然日,并提供7/15/30天辅助对比;当前库存独立固定版本。
每次请求使用REPEATABLE_READ。snapshotContext为服务端重建验证的版本与日代表证据,
不作为授权凭据;账号、仓库、截止日、基准、queryAsOf、版本对、范围摘要、
重建状态及全部max(30,baseWindowDays)日映射(含缺失日期)逐项重建比较;无效或过期要求整体刷新。
历史代表须同范围、正式计划采集、已发布且DAILY_SCHEDULED_FIRST_V1合格标记。
每次请求使用REPEATABLE_READ,一致性读固定销量、当前库存及历史日版本映射。
snapshotContext保存签名压缩的准确版本清单与缺失日期,不作为授权凭据;后续页按准确ID校验
账号、仓库、范围及失效标记,不用墙钟时刻重新推导日头。服务重启、校正失效、规则变更要求整体刷新。
历史分母只使用FLOW_EOD_V1日末全品质实物库存,以完整流水及独立库存锚点还原;不与旧计划样本混算。
上下文有效期最多30分钟;客户端不能直接指定runId。SKU/关注分页不改变概览范围。
历史销量完整性不依赖当前库存新鲜度;缺样本不补零、不缩短周转分母。
历史销量完整性不依赖当前库存新鲜度;缺日保持未知、不缩短周转分母。sampleReasonCodes包含流水或独立库存对账失败原因。dailySamples.dayVersionId表示历史日版本标识,非原始库存run。
security: [{bearerAuth: []}]
paths:
/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'}
inventoryObservedFrom: {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:
type: array
minItems: 30
maxItems: 366
items:
type: object
required: [date, runId]
required: [date, dayVersionId]
properties:
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}}
accumulationStartedOn: {type: string, format: date, nullable: true}
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(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);
}
@Test void sameContextFreezesAllThirtyDatesAndMissingSamples() {
@@ -40,10 +41,12 @@ class InventoryTurnoverContextTest {
assertEquals(first,second.response());
assertEquals(InventoryTurnoverSql.input(request(first.snapshotContext()),contexts.resolve(request(first.snapshotContext()))).days(),
InventoryTurnoverSql.input(page2,second).days());
verify(mapper,times(4)).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
// 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());
verify(mapper,times(1)).samples("demo","W",IH,utc(NOW),END.minusDays(29),END.plusDays(1));
// Even a late commit bearing an older wall-clock timestamp cannot change the exact mapping.
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of(new InventoryTurnoverReadMapper.DayVersion(END,101L)));
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())));
}
@Test void fullYearContextRemainsSmallAndReconstructsEveryMissingDate() {
@@ -53,14 +56,12 @@ class InventoryTurnoverContextTest {
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);
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));
}
@Test void rejectsTamperedVersionsMappingWarehouseAndExpiry() throws Exception {
String token=contexts.resolve(request(null)).response().snapshotContext();
var evidence=json.readTree(Base64.getUrlDecoder().decode(token));
((com.fasterxml.jackson.databind.node.ObjectNode)evidence).put("salesRun",999L);
String tampered=Base64.getUrlEncoder().encodeToString(json.writeValueAsBytes(evidence));
String tampered=(token.charAt(0)=='A'?"B":"A")+token.substring(1);
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);
assertThrows(BizException.class,()->contexts.resolve(another));
@@ -81,7 +82,7 @@ class InventoryTurnoverContextTest {
}
@Test void rejectsDuplicateRepresentativesAndScopeDrift() {
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)));
when(analytics.publishedRun(11L)).thenReturn(run(11,2,IH));
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 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));}
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);}
@@ -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_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_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();}
private void master() throws Exception {
@@ -285,7 +287,7 @@ class InventoryTurnoverSqlTest {
}
@Test void invalidHistoricalUnitOnlyInvalidatesItsSampleAndNeverActualOwnerSales() throws Exception {
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 row=query("page",p).get(0);
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 row=query("page",p).get(0);
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"));
}
@Test void emptyWarehouseChartDistinguishesCoveredZeroFromMissingEvidence() throws Exception {
@@ -409,7 +411,7 @@ class InventoryTurnoverSqlTest {
@Test void sparseSamplesKeepExactDateComplementAndFullOwnerDenominator() throws Exception {
seed("A","ONE",30);seed("A","TWO",30);
// 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)) {
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);
@@ -434,7 +436,8 @@ class InventoryTurnoverSqlTest {
long first=9_007_199_254_740_993L;
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_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<>();
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")) {
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 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);
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);
@@ -541,16 +544,16 @@ class InventoryTurnoverSqlTest {
}
@Test void presentInvalidSamplesNeverQualifyAndOwnerZeroNeedsEverySku() throws Exception {
seed("A","ONE",30);seed("A","TWO",30);
String key=" WHERE snapshot_run_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")) {
if("profile".equals(mutation))execute("UPDATE ll_selfop_sku_profile SET quantity_unit='UNKNOWN' WHERE profile_run_id=100 AND sku_code='TWO'");
else execute("UPDATE ll_selfop_inventory_snapshot SET "+mutation+key);
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'")) {
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_daily SET "+mutation+key);
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"));
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_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);
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);
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_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 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());
@@ -622,7 +626,7 @@ class InventoryTurnoverSqlTest {
}
@Test void tinyQualifiedDailySumUsesOneDivisionForSkuAndOwnerThroughProductionCtes() throws Exception {
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'");
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);
@@ -636,7 +640,7 @@ class InventoryTurnoverSqlTest {
}
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")) {
var q=request(group,"ALL","TURNOVER_DESC",null,null,1,20);
for(var row:reportRows(input(q,30,30,true))) {
@@ -668,7 +672,7 @@ class InventoryTurnoverSqlTest {
}
@Test void turnoverOrderingUsesUnroundedRatioBeforeStableSkuAndOwnerPages() throws Exception {
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'");
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);
@@ -687,6 +691,7 @@ class InventoryTurnoverSqlTest {
void numericSortsUseFormalValuesInBothDirectionsAndKeepUnknownLast(String metric) throws Exception {
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_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");
for(String group:List.of("SKU","OWNER")) {
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')");
}
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)");
}
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 SelfopApiBudget budget(){return SelfopSupport.read(account.getApiBudgetJson(),SelfopApiBudget.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(){
continuousBusinessRoot();task.setTaskType("MASTER_DATA");task.setQueryFromUtc(null);task.setQueryUntilUtc(null);
task.setPlannedAtUtc(now().plusHours(8).toLocalDate().atStartOfDay().minusHours(8));