Merge latest test into dev [skip ci]

# Conflicts:
#	deploy/sql/README.md
#	deploy/sql/海外区域统一增量-MySQL8.sql
This commit is contained in:
2026-10-14 17:26:37 +08:00
48 changed files with 838 additions and 67 deletions
+4 -2
View File
@@ -28,6 +28,8 @@
平台自营领星统计十张 `ll_selfop_*` 表直接同步 Lastleg 源基线及海外统一空库基线;时间使用 UTC 存储及已确认北京时间业务日,数量使用 DECIMAL 保留 PCS 精度,维持独立发布版本。包含账号共享预算/冷却、验证根用途与硬预算、TEST BUSINESS 根会话持久预算、稳定任务号、阶段进度/心跳和旧版替代时间;任务的 `business_root_task_id` 标记共享根,只有根保存 `business_budget_json`,生产任务不使用该测试预算。库存 run 包含每日代表日期、选择规则及账号/仓库/日期唯一约束,代表库存与 profile 不适用销量副本淘汰。IAM 权限源为 `iam/src/main/resources/db/selfoperated-analytics-permissions.sql`,自营业务下经营总览、销量分析、库存分析、数据采集的菜单资源与独立读写权限同步国内统一基线,仅平台管理员默认授权。执行和部署状态只维护于工作区测试环境主契约;真实采集模式默认 OFF。 平台自营领星统计十张 `ll_selfop_*` 表直接同步 Lastleg 源基线及海外统一空库基线;时间使用 UTC 存储及已确认北京时间业务日,数量使用 DECIMAL 保留 PCS 精度,维持独立发布版本。包含账号共享预算/冷却、验证根用途与硬预算、TEST BUSINESS 根会话持久预算、稳定任务号、阶段进度/心跳和旧版替代时间;任务的 `business_root_task_id` 标记共享根,只有根保存 `business_budget_json`,生产任务不使用该测试预算。库存 run 包含每日代表日期、选择规则及账号/仓库/日期唯一约束,代表库存与 profile 不适用销量副本淘汰。IAM 权限源为 `iam/src/main/resources/db/selfoperated-analytics-permissions.sql`,自营业务下经营总览、销量分析、库存分析、数据采集的菜单资源与独立读写权限同步国内统一基线,仅平台管理员默认授权。执行和部署状态只维护于工作区测试环境主契约;真实采集模式默认 OFF。
客户/商品资料基线为 `lastleg-service/src/main/resources/db/selfop-master-data.sql`,仅新增 `ll_selfop_owner`、`ll_selfop_product` 两表,并同步在 Lastleg 和海外统一空库基线。按来源账号、货主编码、SKU 和完整资料版本关联,不创建平台客户、收费对象或独立映射表;未知实测尺寸保持 NULL。对 TEST 已有库只审阅并授权这两张新表的 CREATE,不能重放统一初始化文件,也不重置采集数据。
## 唯一入口与区域边界 ## 唯一入口与区域边界
两个统一 SQL 文件仍只用于空库初始化,名称中的“增量”不表示可向已有业务库重放。已有生产库的受控结构补齐单独放在 `migrations/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。 两个统一 SQL 文件仍只用于空库初始化,名称中的“增量”不表示可向已有业务库重放。已有生产库的受控结构补齐单独放在 `migrations/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。
@@ -35,7 +37,7 @@
| 区域 | 完整入口 | 服务 schema | 表数 | | 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:| |---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 | | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 197 | | 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 199 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 - 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
@@ -57,7 +59,7 @@
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/197`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/199`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
## 平台管理员密码 ## 平台管理员密码
+35 -1
View File
@@ -4319,4 +4319,38 @@ CREATE TABLE ll_cargo_discard_photo (
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no) KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 197 AS initialized_tables; CREATE TABLE ll_selfop_owner (
id BIGINT NOT NULL COMMENT '主键',
run_id BIGINT NOT NULL COMMENT '完整资料采集版本',
source_account VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源账号',
customer_code VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主编码',
customer_name VARCHAR(255) NULL COMMENT '领星货主名称',
source_status VARCHAR(32) NULL COMMENT '来源启停状态',
observed_at_utc DATETIME(6) NOT NULL COMMENT '资料观测时间',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_owner_version (run_id,source_account,customer_code),
KEY idx_selfop_owner_lookup (source_account,customer_code,run_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星货主资料版本';
CREATE TABLE ll_selfop_product (
id BIGINT NOT NULL COMMENT '主键',
run_id BIGINT NOT NULL COMMENT '完整资料采集版本',
source_account VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源账号',
customer_code VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主编码',
sku_code VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '货主商品编码',
product_name VARCHAR(255) NULL COMMENT '商品名称',
declared_length DECIMAL(24,6) NULL COMMENT '商品资料长度',
declared_width DECIMAL(24,6) NULL COMMENT '商品资料宽度',
declared_height DECIMAL(24,6) NULL COMMENT '商品资料高度',
declared_size_unit VARCHAR(32) NULL COMMENT '商品资料尺寸单位',
measured_length DECIMAL(24,6) NULL COMMENT '仓库实测长度',
measured_width DECIMAL(24,6) NULL COMMENT '仓库实测宽度',
measured_height DECIMAL(24,6) NULL COMMENT '仓库实测高度',
measured_size_unit VARCHAR(32) NULL COMMENT '仓库实测尺寸单位',
observed_at_utc DATETIME(6) NOT NULL COMMENT '资料观测时间',
PRIMARY KEY (id),
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='自营领星商品资料版本';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 199 AS initialized_tables;
@@ -88,6 +88,24 @@ public class LingxingInternalController {
return ApiResponse.success(client.queryInventory(request, context(context))); return ApiResponse.success(client.queryInventory(request, context(context)));
} }
@PostMapping("/customers/query")
public ApiResponse<QueryPage<Customer>> customers(
@RequestHeader(value="X-Internal-Service-Token",required=false) String token,
@RequestHeader(value="X-Lingxing-Egress-Context",required=false) String context,
@Valid @RequestBody WarehouseQueryRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(client.queryCustomers(request,context(context)));
}
@PostMapping("/products/query")
public ApiResponse<QueryPage<Product>> products(
@RequestHeader(value="X-Internal-Service-Token",required=false) String token,
@RequestHeader(value="X-Lingxing-Egress-Context",required=false) String context,
@Valid @RequestBody ProductQueryRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(client.queryProducts(request,context(context)));
}
private EgressContext context(String header) { private EgressContext context(String header) {
if (header == null || header.isBlank() || header.length() > 2048 || !header.chars().allMatch(c -> c >= 32 && c <= 126)) if (header == null || header.isBlank() || header.length() > 2048 || !header.chars().allMatch(c -> c >= 32 && c <= 126))
throw invalidContext(); throw invalidContext();
@@ -6,7 +6,7 @@ import java.time.Instant;
public final class LingxingEgressDtos { public final class LingxingEgressDtos {
private LingxingEgressDtos() { } private LingxingEgressDtos() { }
public enum CollectionMode { OFF, VALIDATION, BUSINESS } public enum CollectionMode { OFF, VALIDATION, BUSINESS }
public enum Operation { WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY } public enum Operation { WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY, CUSTOMERS, PRODUCTS }
public record EgressContext(String sourceAccount, CollectionMode executionPurpose, String rootTaskId, public record EgressContext(String sourceAccount, CollectionMode executionPurpose, String rootTaskId,
String sessionId, String attemptId, String exclusiveGrantId) { String sessionId, String attemptId, String exclusiveGrantId) {
public boolean wellFormed() { public boolean wellFormed() {
@@ -19,6 +19,11 @@ public final class LingxingWmsDtos {
@Min(1) int current, @Min(1) @Max(100) int size) { } @Min(1) int current, @Min(1) @Max(100) int size) { }
public record WarehouseQueryRequest(@Min(1) int current, @Min(1) @Max(1000) int size) { } public record WarehouseQueryRequest(@Min(1) int current, @Min(1) @Max(1000) int size) { }
public record ProductQueryRequest(@Min(1) int current, @Min(1) @Max(100) int size) { }
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,
BigDecimal wmsLength, BigDecimal wmsWidth, BigDecimal wmsHeight, String wmsSizeUnit) { }
public record LocateRequest(@NotBlank @Size(max=128) String deliveryNo, public record LocateRequest(@NotBlank @Size(max=128) String deliveryNo,
@Min(1) int current, @Min(1) @Max(1000) int size) { } @Min(1) int current, @Min(1) @Max(1000) int size) { }
public record DetailRequest(@NotBlank @Size(max=64) String warehouseCode, public record DetailRequest(@NotBlank @Size(max=64) String warehouseCode,
@@ -133,6 +133,24 @@ public final class LingxingWmsClient {
optionalText(data, "outboundTime"), lines, 200, clock.instant(), metadata); optionalText(data, "outboundTime"), lines, 200, clock.instant(), metadata);
} }
public QueryPage<Customer> queryCustomers(WarehouseQueryRequest request, EgressContext context) {
if (request == null) throw badRequest();
validatePagination(request.current(), request.size(), 1000);
return execute("/openapi/v2/customer/list", pagination(request.current(), request.size()), context, Operation.CUSTOMERS,
(data, metadata) -> mappedPage(data, request.current(), request.size(), "page", "pageSize", false, metadata,
row -> new Customer(requiredText(row,"customerCode",64), optionalText(row,"customerName"), optionalText(row,"status"))));
}
public QueryPage<Product> queryProducts(ProductQueryRequest request, EgressContext context) {
if (request == null) throw badRequest();
validatePagination(request.current(), request.size(), 100);
return execute("/openapi/v2/product/page", pagination(request.current(), request.size()), context, Operation.PRODUCTS,
(data, metadata) -> mappedPage(data, request.current(), request.size(), "page", "pageSize", false, metadata,
row -> new Product(requiredText(row,"customerCode",64), requiredText(row,"sku",128), optionalText(row,"productName"),
quantity(row.get("length")),quantity(row.get("width")),quantity(row.get("height")),optionalText(row,"sizeUnit"),
quantity(row.get("wmsLength")),quantity(row.get("wmsWidth")),quantity(row.get("wmsHeight")),optionalText(row,"wmsSizeUnit"))));
}
public QueryPage<ProductStock> queryInventory(InventoryQueryRequest request, EgressContext context) { public QueryPage<ProductStock> queryInventory(InventoryQueryRequest request, EgressContext context) {
if (request == null) throw badRequest(); if (request == null) throw badRequest();
requireInput(request.warehouseCode(), 64); requireInput(request.warehouseCode(), 64);
@@ -51,6 +51,40 @@ x-lastleg-egress-coordination:
security: security:
- InternalServiceToken: [] - InternalServiceToken: []
paths: paths:
/internal/integrations/lingxing/wms/customers/query:
post:
summary: Query one page of customers using the existing account egress permit
description: Calls /openapi/v2/customer/list. BUSINESS MASTER_DATA tasks only; no retries or separate egress path. Dimensions and units remain separate; blanks are null.
parameters:
- {$ref: '#/components/parameters/LingxingEgressContext'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/WarehouseQueryRequest'}
responses:
'200':
description: Standard envelope with one page or sanitized ProviderFailure.
content:
application/json:
schema: {$ref: '#/components/schemas/CustomerResponse'}
/internal/integrations/lingxing/wms/products/query:
post:
summary: Query one page of products using the existing account egress permit
description: Calls /openapi/v2/product/page. BUSINESS MASTER_DATA tasks only; no retries or separate egress path. Dimensions and units remain separate; blanks are null.
parameters:
- {$ref: '#/components/parameters/LingxingEgressContext'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ProductQueryRequest'}
responses:
'200':
description: Standard envelope with one page or sanitized ProviderFailure.
content:
application/json:
schema: {$ref: '#/components/schemas/ProductResponse'}
/internal/integrations/lingxing/wms/egress/runtime: /internal/integrations/lingxing/wms/egress/runtime:
get: get:
operationId: lingxingEgressRuntime operationId: lingxingEgressRuntime
@@ -175,6 +209,68 @@ components:
in: header in: header
name: X-Internal-Service-Token name: X-Internal-Service-Token
schemas: schemas:
CustomerPage:
allOf:
- {$ref: '#/components/schemas/Pagination'}
- type: object
required: [records]
properties:
records: {type: array, items: {$ref: '#/components/schemas/Customer'}}
CustomerResponse:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- type: object
properties:
data:
nullable: true
oneOf:
- {$ref: '#/components/schemas/CustomerPage'}
- {$ref: '#/components/schemas/ProviderFailure'}
ProductPage:
allOf:
- {$ref: '#/components/schemas/Pagination'}
- type: object
required: [records]
properties:
records: {type: array, items: {$ref: '#/components/schemas/Product'}}
ProductResponse:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- type: object
properties:
data:
nullable: true
oneOf:
- {$ref: '#/components/schemas/ProductPage'}
- {$ref: '#/components/schemas/ProviderFailure'}
ProductQueryRequest:
type: object
required: [current, size]
properties:
current: {type: integer, minimum: 1}
size: {type: integer, minimum: 1, maximum: 100}
Customer:
type: object
required: [customerCode]
properties:
customerCode: {type: string}
customerName: {type: string, nullable: true}
status: {type: string, nullable: true}
Product:
type: object
required: [customerCode, sku]
properties:
customerCode: {type: string}
sku: {type: string}
productName: {type: string, nullable: true}
length: {type: number, nullable: true}
width: {type: number, nullable: true}
height: {type: number, nullable: true}
wmsLength: {type: number, nullable: true}
wmsWidth: {type: number, nullable: true}
wmsHeight: {type: number, nullable: true}
sizeUnit: {type: string, nullable: true}
wmsSizeUnit: {type: string, nullable: true}
EgressRuntimeSnapshot: EgressRuntimeSnapshot:
type: object type: object
additionalProperties: false additionalProperties: false
@@ -219,7 +315,7 @@ components:
{"current":1,"size":2,"whCode":"WH-DEMO","isHideInventory":0} {"current":1,"size":2,"whCode":"WH-DEMO","isHideInventory":0}
EgressOperation: EgressOperation:
type: string type: string
enum: [WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY] enum: [WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY, CUSTOMERS, PRODUCTS]
EgressMetadata: EgressMetadata:
type: object type: object
required: [attemptId, actualHttpAttempts, accountPaused] required: [attemptId, actualHttpAttempts, accountPaused]
@@ -22,6 +22,37 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
/** Synthetic loopback transport only; no supplier or application services. */ /** Synthetic loopback transport only; no supplier or application services. */
class LingxingWmsClientTest { class LingxingWmsClientTest {
@Test void 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());
assertThat(path.get()).isEqualTo("/openapi/v2/customer/list");
assertThat(result.records()).containsExactly(new Customer("C","示例货主","0"));
assertThat(completion.get().operation()).isEqualTo(Operation.CUSTOMERS);
}
@Test void productDirectoryRetainsDeclaredDimensionsAndUnknownMeasurements() {
response.set(page("page","pageSize",1,100,1,1,"{\"customerCode\":\"C\",\"sku\":\"SKU\",\"productName\":\"商品\",\"length\":5,\"width\":11,\"height\":5,\"sizeUnit\":\"cm\",\"wmsLength\":\"\",\"wmsWidth\":\"\",\"wmsHeight\":\"\",\"wmsSizeUnit\":\"\"}"));
var result=client().queryProducts(new ProductQueryRequest(1,100),context());
assertThat(result.records().get(0).length()).isEqualByComparingTo("5");
assertThat(result.records().get(0).wmsLength()).isNull();
assertThat(result.records().get(0).sku()).isEqualTo("SKU");
assertThat(completion.get().operation()).isEqualTo(Operation.PRODUCTS);
assertThatThrownBy(()->client().queryProducts(new ProductQueryRequest(1,101),context())).isInstanceOf(LingxingWmsException.class);
assertThat(calls.get()).isEqualTo(1);
}
@Test void masterDataUsesSupplierResponsePaginationAndRejectsWrongPage() throws Exception {
response.set(page("page","pageSize",2,1,2,2,"{\"customerCode\":\"C\",\"sku\":\"SKU\"}"));
var result=client().queryProducts(new ProductQueryRequest(2,1),context());
assertThat(result.current()).isEqualTo(2);
assertThat(result.sourceCurrent()).isEqualTo(2);
assertThat(result.sourceSize()).isEqualTo(1);
var request=mapper.readTree(body.get()).path("data");
assertThat(request.path("current").asInt()).isEqualTo(2);
assertThat(request.path("size").asInt()).isEqualTo(1);
assertThat(request.has("page")).isFalse();
response.set(page("page","pageSize",1,1,2,2,"{\"customerCode\":\"C\",\"sku\":\"SKU\"}"));
assertThatThrownBy(()->client().queryProducts(new ProductQueryRequest(2,1),context()))
.isInstanceOf(LingxingWmsException.class).hasMessageContaining("CURRENT_MISMATCH");
}
private static final Instant NOW = Instant.parse("2026-10-12T00:00:00Z"); private static final Instant NOW = Instant.parse("2026-10-12T00:00:00Z");
private static final String PRIVATE = "private-secret@example.test"; private static final String PRIVATE = "private-secret@example.test";
private static final String ORDER = """ private static final String ORDER = """
@@ -13,6 +13,8 @@ public interface LingxingAnalyticsClient {
Page<Summary> locate(SelfopRequestContext context, String deliveryNo, int current, int size); Page<Summary> locate(SelfopRequestContext context, String deliveryNo, int current, int size);
Order detail(SelfopRequestContext context, String warehouseCode, String customerCode, String deliveryNo); Order detail(SelfopRequestContext context, String warehouseCode, String customerCode, String deliveryNo);
Page<Stock> inventory(SelfopRequestContext context, String warehouseCode, int current, int size); Page<Stock> inventory(SelfopRequestContext context, String warehouseCode, int current, int size);
Page<SelfopPayloads.Owner> customers(SelfopRequestContext context, int current, int size);
Page<SelfopPayloads.Product> products(SelfopRequestContext context, int current, int size);
record Page<T>(List<T> records, Integer current, Integer size, Long total, Integer pages) { record Page<T>(List<T> records, Integer current, Integer size, Long total, Integer pages) {
public Page { records = List.copyOf(records); } public Page { records = List.copyOf(records); }
@@ -109,6 +109,19 @@ public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient {
}); });
} }
@Override public Page<SelfopPayloads.Owner> customers(SelfopRequestContext context,int current,int size) {
gate(context);paging(current,size,1000);
return page(post(context,"/customers/query",Map.of("current",current,"size",size)),current,size,
n -> new SelfopPayloads.Owner(required(n,"customerCode",64),optional(n,"customerName"),optional(n,"status")));
}
@Override public Page<SelfopPayloads.Product> products(SelfopRequestContext context,int current,int size) {
gate(context);paging(current,size,100);
return page(post(context,"/products/query",Map.of("current",current,"size",size)),current,size,
n -> new SelfopPayloads.Product(required(n,"customerCode",64),required(n,"sku",128),optional(n,"productName"),
quantity(n,"length"),quantity(n,"width"),quantity(n,"height"),optional(n,"sizeUnit"),
quantity(n,"wmsLength"),quantity(n,"wmsWidth"),quantity(n,"wmsHeight"),optional(n,"wmsSizeUnit")));
}
private Summary summary(String account, JsonNode n) { private Summary summary(String account, JsonNode n) {
List<SelfopPayloads.Item> items = null; List<SelfopPayloads.Item> items = null;
JsonNode products = n.get("productList"); JsonNode products = n.get("productList");
@@ -145,6 +158,8 @@ public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient {
case "/outbound-orders/locate" -> Operation.OUTBOUND_LOCATE; case "/outbound-orders/locate" -> Operation.OUTBOUND_LOCATE;
case "/outbound-orders/detail" -> Operation.OUTBOUND_DETAIL; case "/outbound-orders/detail" -> Operation.OUTBOUND_DETAIL;
case "/product-inventory/query" -> Operation.PRODUCT_INVENTORY; case "/product-inventory/query" -> Operation.PRODUCT_INVENTORY;
case "/customers/query" -> Operation.CUSTOMERS;
case "/products/query" -> Operation.PRODUCTS;
default -> throw failure("EGRESS_OPERATION_INVALID"); default -> throw failure("EGRESS_OPERATION_INVALID");
}; };
int size = fields.containsKey("size") ? ((Number)fields.get("size")).intValue() : 0; int size = fields.containsKey("size") ? ((Number)fields.get("size")).intValue() : 0;
@@ -64,6 +64,12 @@ public class PlatformAnalyticsController {
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query,@ModelAttribute AnalyticsInventoryOptions options){ @AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query,@ModelAttribute AnalyticsInventoryOptions options){
requirePlatform(user);return ApiResponse.success(service.owners(query,options)); requirePlatform(user);return ApiResponse.success(service.owners(query,options));
} }
@GetMapping("/owner-directory")
@Operation(summary="读取固定查询时刻已发布的货主名称目录")
public ApiResponse<java.util.List<AnalyticsResponses.OwnerOption>> ownerDirectory(
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query){
requirePlatform(user);return ApiResponse.success(service.ownerDirectory(query));
}
@GetMapping("/owner-overview") @GetMapping("/owner-overview")
@Operation(summary="本仓一个货主完整候选的30日经营概览") @Operation(summary="本仓一个货主完整候选的30日经营概览")
public ApiResponse<AnalyticsResponses.OwnerOverview> ownerOverview( public ApiResponse<AnalyticsResponses.OwnerOverview> ownerOverview(
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopPayloads;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface SelfopMasterDataMapper {
@Insert("INSERT INTO ll_selfop_owner(id,run_id,source_account,customer_code,customer_name,source_status,observed_at_utc) VALUES(#{id},#{run},#{account},#{row.customerCode},#{row.customerName},#{row.status},#{now})")
void insertOwner(@Param("id") long id,@Param("run") long run,@Param("account") String account,@Param("row") SelfopPayloads.Owner row,@Param("now") LocalDateTime now);
@Insert("""
INSERT INTO ll_selfop_product(id,run_id,source_account,customer_code,sku_code,product_name,
declared_length,declared_width,declared_height,declared_size_unit,measured_length,measured_width,measured_height,measured_size_unit,observed_at_utc)
VALUES(#{id},#{run},#{account},#{row.customerCode},#{row.sku},#{row.productName},
#{row.length},#{row.width},#{row.height},#{row.sizeUnit},#{row.wmsLength},#{row.wmsWidth},#{row.wmsHeight},#{row.wmsSizeUnit},#{now})
""")
void insertProduct(@Param("id") long id,@Param("run") long run,@Param("account") String account,@Param("row") SelfopPayloads.Product row,@Param("now") LocalDateTime now);
@Select("SELECT COUNT(*) FROM ll_selfop_owner WHERE run_id=#{run} AND source_account=#{account}")
long ownerCount(@Param("run") long run,@Param("account") String account);
@Select("SELECT COUNT(*) FROM ll_selfop_product WHERE run_id=#{run} AND source_account=#{account}")
long productCount(@Param("run") long run,@Param("account") String account);
@Select("""
SELECT r.id FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id
WHERE r.source_account=#{account} AND t.source_account=#{account} AND t.task_type='MASTER_DATA'
AND t.task_role='ACCOUNT_DISCOVERY' AND r.execution_purpose='BUSINESS' AND t.execution_purpose='BUSINESS'
AND r.status='PUBLISHED' AND r.coverage_status='COMPLETE' AND r.published_at_utc<=#{asOf}
ORDER BY r.published_at_utc DESC,r.id DESC LIMIT 1
""")
Long publishedAt(@Param("account") String account,@Param("asOf") LocalDateTime asOf);
@Select("SELECT customer_code,customer_name,source_status AS status FROM ll_selfop_owner WHERE source_account=#{account} AND run_id=#{run} ORDER BY customer_code")
List<SelfopPayloads.Owner> owners(@Param("account") String account,@Param("run") long run);
}
@@ -37,6 +37,15 @@ public final class SelfopCollectionPolicy {
if(task==null||!"BUSINESS".equals(task.getExecutionPurpose())||task.getUpstreamTaskId()!=null if(task==null||!"BUSINESS".equals(task.getExecutionPurpose())||task.getUpstreamTaskId()!=null
||task.getValidationRootTaskId()!=null||!CONTINUOUS_PLANNER.equals(task.getRequestedBy())||task.getScopeHash()==null||!task.getScopeHash().matches("[a-f0-9]{64}"))return false; ||task.getValidationRootTaskId()!=null||!CONTINUOUS_PLANNER.equals(task.getRequestedBy())||task.getScopeHash()==null||!task.getScopeHash().matches("[a-f0-9]{64}"))return false;
String key=task.getRequestKey();if(key==null)return false; String key=task.getRequestKey();if(key==null)return false;
if("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;
try{Map<?,?> q=read(task.getQueryScopeJson(),Map.class);
return key.equals("continuous:master:"+task.getPlannedAtUtc().plusHours(8).toLocalDate()+":"+task.getScopeHash().substring(0,12))
&&q!=null&&q.size()==2&&"MASTER_DATA".equals(q.get("dataFamily"))&&task.getSourceAccount().equals(q.get("sourceAccount"))
&&task.getScopeHash().equals(SelfopSupport.hash(new java.util.TreeMap<>(q)));
}catch(RuntimeException invalid){return false;}
}
if("ACCOUNT_DISCOVERY".equals(task.getTaskRole())&&"BACKFILL".equals(task.getTaskType())&&task.getDiscoveryScopeId()!=null if("ACCOUNT_DISCOVERY".equals(task.getTaskRole())&&"BACKFILL".equals(task.getTaskType())&&task.getDiscoveryScopeId()!=null
&&task.getQueryFromUtc()!=null&&task.getQueryUntilUtc()!=null&&task.getQueryUntilUtc().equals(task.getQueryFromUtc().plusDays(1))){ &&task.getQueryFromUtc()!=null&&task.getQueryUntilUtc()!=null&&task.getQueryUntilUtc().equals(task.getQueryFromUtc().plusDays(1))){
var local=task.getQueryFromUtc().plusHours(8); var local=task.getQueryFromUtc().plusHours(8);
@@ -5,6 +5,10 @@ import java.util.List;
public final class SelfopPayloads { public final class SelfopPayloads {
private SelfopPayloads() { } private SelfopPayloads() { }
public record Owner(String customerCode, String customerName, String status) { }
public record Product(String customerCode, String sku, String productName,
BigDecimal length, BigDecimal width, BigDecimal height, String sizeUnit,
BigDecimal wmsLength, BigDecimal wmsWidth, BigDecimal wmsHeight, String wmsSizeUnit) { }
public record Order(String sourceAccount, String customerCode, String warehouseCode, public record Order(String sourceAccount, String customerCode, String warehouseCode,
String deliveryNo, String sourceNo, Integer sourceStatus, String sourceCreatedTime, String deliveryNo, String sourceNo, Integer sourceStatus, String sourceCreatedTime,
String sourceOutboundTime, List<Item> items) { } String sourceOutboundTime, List<Item> items) { }
@@ -11,7 +11,7 @@ import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
public final class SelfopProgressProjection { public final class SelfopProgressProjection {
private SelfopProgressProjection() { } private SelfopProgressProjection() { }
private static final Set<String> PHASES = Set.of("WAITING_SOURCE", "DIRECTORY", "DETAIL", "INVENTORY", private static final Set<String> PHASES = Set.of("WAITING_SOURCE", "DIRECTORY", "DETAIL", "INVENTORY",
"VALIDATION", "MERGE", "BUILD", "PUBLISH", "WAITING", "SAMPLE_DONE", "FAILED"); "VALIDATION", "MERGE", "BUILD", "PUBLISH", "WAITING", "SAMPLE_DONE", "FAILED", "CUSTOMERS", "PRODUCTS");
/** Pure projection for callers already holding the scope/task/run locks and fence. */ /** Pure projection for callers already holding the scope/task/run locks and fence. */
public static void applyCheckpoint(SelfopSyncTask task,SelfopSyncRun run,String phase,String unit,long completed,Long total,int scan) { public static void applyCheckpoint(SelfopSyncTask task,SelfopSyncRun run,String phase,String unit,long completed,Long total,int scan) {
applyCheckpoint(task,run,phase,unit,completed,total,scan,true); applyCheckpoint(task,run,phase,unit,completed,total,scan,true);
@@ -21,17 +21,18 @@ public class AnalyticsQueryService {
private final AnalyticsReadMapper mapper; private final AnalyticsReadMapper mapper;
private final ObjectMapper json; private final ObjectMapper json;
private final Clock clock; private final Clock clock;
private final SelfopOwnerDirectory ownerDirectory;
@Value("${baiyue.self-operated-analytics.max-query-days:366}") private int maxQueryDays=366; @Value("${baiyue.self-operated-analytics.max-query-days:366}") private int maxQueryDays=366;
@Value("${baiyue.self-operated-analytics.sales-retention-days:7}") private int retentionDays=7; @Value("${baiyue.self-operated-analytics.sales-retention-days:7}") private int retentionDays=7;
@Autowired public AnalyticsQueryService(AnalyticsReadMapper mapper,ObjectMapper json){this(mapper,json,Clock.systemUTC());} @Autowired public AnalyticsQueryService(AnalyticsReadMapper mapper,ObjectMapper json,SelfopOwnerDirectory ownerDirectory){this(mapper,json,Clock.systemUTC(),ownerDirectory);}
AnalyticsQueryService(AnalyticsReadMapper mapper,ObjectMapper json,Clock clock){this.mapper=mapper;this.json=json;this.clock=clock;} AnalyticsQueryService(AnalyticsReadMapper mapper,ObjectMapper json,Clock clock,SelfopOwnerDirectory ownerDirectory){this.mapper=mapper;this.json=json;this.clock=clock;this.ownerDirectory=ownerDirectory;}
record Snapshot(AnalyticsQuery q,Run sales,Run inventory,boolean sourceKnown,boolean trusted, record Snapshot(AnalyticsQuery q,Run sales,Run inventory,boolean sourceKnown,boolean trusted,
LocalDate fullFrom,LocalDate fullUntil,SalesRangePolicy.Range range,AnalyticsResponses.Context context){} LocalDate fullFrom,LocalDate fullUntil,SalesRangePolicy.Range range,AnalyticsResponses.Context context){}
public AnalyticsResponses.Page<AnalyticsResponses.SalesSummary> salesSummary(AnalyticsQuery q,SalesSort salesSort){ public AnalyticsResponses.Page<AnalyticsResponses.SalesSummary> salesSummary(AnalyticsQuery q,SalesSort salesSort){
Snapshot s=context(q,true,false,false,null); AnalyticsSqlRequest p=request(s,"SUMMARY"); Snapshot s=context(q,true,false,false,null); AnalyticsSqlRequest p=request(s,"SUMMARY");
long count=mapper.count(p); long count=mapper.count(p);
var records=mapper.salesPage(p,salesSort).stream().map(r->summary(s,r)).toList(); var names=ownerNames(s);var records=mapper.salesPage(p,salesSort).stream().map(r->summary(s,r,names.get(r.customerCode()))).toList();
return page(s,records,count,Map.of()); return page(s,records,count,Map.of());
} }
public AnalyticsResponses.Trend dailySales(AnalyticsQuery q){ public AnalyticsResponses.Trend dailySales(AnalyticsQuery q){
@@ -64,7 +65,7 @@ public class AnalyticsQueryService {
var totals=SalesBucketPolicy.aggregate(evaluated); var totals=SalesBucketPolicy.aggregate(evaluated);
var summary=new AnalyticsResponses.SalesSummary(q.sourceAccount(),q.warehouseCode(),q.customerCode(),q.sku(),name(sp,ip),unit, var summary=new AnalyticsResponses.SalesSummary(q.sourceAccount(),q.warehouseCode(),q.customerCode(),q.sku(),name(sp,ip),unit,
totals.outboundQuantity(),totals.observedQuantity(),totals.dataStatus(),totals.reasonCodes(), totals.outboundQuantity(),totals.observedQuantity(),totals.dataStatus(),totals.reasonCodes(),
totals.completeDays(),totals.partialDays(),totals.unknownDays()); totals.completeDays(),totals.partialDays(),totals.unknownDays(),ownerNames(s).get(q.customerCode()));
return new AnalyticsResponses.Trend(s.context,summary,List.copyOf(points)); return new AnalyticsResponses.Trend(s.context,summary,List.copyOf(points));
} }
public AnalyticsResponses.Page<AnalyticsResponses.Metric> hotSkus(AnalyticsQuery q){return metrics(q,"HOT",true);} public AnalyticsResponses.Page<AnalyticsResponses.Metric> hotSkus(AnalyticsQuery q){return metrics(q,"HOT",true);}
@@ -73,11 +74,13 @@ public class AnalyticsQueryService {
o.validate("INVENTORY",q);Snapshot s=context(q,false,false,false,o);var p=request(s,"INVENTORY"); o.validate("INVENTORY",q);Snapshot s=context(q,false,false,false,o);var p=request(s,"INVENTORY");
CoverageDistributionPolicy.requireDrilldown(q,o,s.context(),inventoryFreshUntil(s),clock.instant()); CoverageDistributionPolicy.requireDrilldown(q,o,s.context(),inventoryFreshUntil(s),clock.instant());
long total=mapper.inventoryCount(p,o); long total=mapper.inventoryCount(p,o);
return page(s,q.offset()>=total?List.of():mapper.inventoryPage(p,o).stream().map(r->inventoryMetric(s,r)).toList(),total,Map.of()); var names=ownerNames(s);
return page(s,q.offset()>=total?List.of():mapper.inventoryPage(p,o).stream().map(r->inventoryMetric(s,r,names.get(r.customerCode()))).toList(),total,Map.of());
} }
public AnalyticsResponses.Page<AnalyticsResponses.OwnerOption> owners(AnalyticsQuery q,AnalyticsInventoryOptions o){ public AnalyticsResponses.Page<AnalyticsResponses.OwnerOption> owners(AnalyticsQuery q,AnalyticsInventoryOptions o){
o.validate("OWNERS",q);Snapshot s=context(q,false,false,false,null);var p=request(s,"OWNERS");long total=mapper.ownerCount(p,o); o.validate("OWNERS",q);Snapshot s=context(q,false,false,false,null);var p=request(s,"OWNERS");long total=mapper.ownerCount(p,o);
return page(s,q.offset()>=total?List.of():mapper.owners(p,o).stream().map(code->new AnalyticsResponses.OwnerOption(code,null)).toList(),total,Map.of()); var names=ownerNames(s);
return page(s,q.offset()>=total?List.of():mapper.owners(p,o).stream().map(code->new AnalyticsResponses.OwnerOption(code,names.get(code))).toList(),total,Map.of());
} }
public AnalyticsResponses.OwnerOverview ownerOverview(AnalyticsQuery q,AnalyticsInventoryOptions o){ public AnalyticsResponses.OwnerOverview ownerOverview(AnalyticsQuery q,AnalyticsInventoryOptions o){
o.validate("OVERVIEW",q);Snapshot s=context(q,false,false,false,null);var raw=mapper.ownerTotals(request(s,"OVERVIEW"),o); o.validate("OVERVIEW",q);Snapshot s=context(q,false,false,false,null);var raw=mapper.ownerTotals(request(s,"OVERVIEW"),o);
@@ -89,11 +92,11 @@ public class AnalyticsQueryService {
var average=new AnalyticsResponses.QuantityTotal(divide30(outbound.overallTotal()),divide30(outbound.knownSubtotal()),outbound.unknownSkuCount(),outbound.dataStatus(),outbound.reasonCodes()); var average=new AnalyticsResponses.QuantityTotal(divide30(outbound.overallTotal()),divide30(outbound.knownSubtotal()),outbound.unknownSkuCount(),outbound.dataStatus(),outbound.reasonCodes());
var physicalSkus=new AnalyticsResponses.QuantityTotal(physical.unknownSkuCount()==0?decimal(totals,"physical_sku_known"):null, var physicalSkus=new AnalyticsResponses.QuantityTotal(physical.unknownSkuCount()==0?decimal(totals,"physical_sku_known"):null,
decimal(totals,"physical_sku_known"),physical.unknownSkuCount(),physical.dataStatus(),physical.reasonCodes()); decimal(totals,"physical_sku_known"),physical.unknownSkuCount(),physical.dataStatus(),physical.reasonCodes());
return new AnalyticsResponses.OwnerOverview(s.context,q.customerCode(),null,count,physicalSkus,physical,quantity(totals,"available_quantity"), return new AnalyticsResponses.OwnerOverview(s.context,q.customerCode(),ownerNames(s).get(q.customerCode()),count,physicalSkus,physical,quantity(totals,"available_quantity"),
quantity(totals,"frozen_quantity"),quantity(totals,"defective_physical_quantity"),quantity(totals,"transit_quantity"),outbound,average, quantity(totals,"frozen_quantity"),quantity(totals,"defective_physical_quantity"),quantity(totals,"transit_quantity"),outbound,average,
risk(totals,"no_outbound",physical,false),risk(totals,"above_threshold",physical,o.coverageDaysThreshold()==null)); risk(totals,"no_outbound",physical,false),risk(totals,"above_threshold",physical,o.coverageDaysThreshold()==null));
} }
private AnalyticsResponses.InventoryMetric inventoryMetric(Snapshot s,Inventory r){ private AnalyticsResponses.InventoryMetric inventoryMetric(Snapshot s,Inventory r,String customerName){
boolean observed=Boolean.TRUE.equals(r.observationEligible());String quality=!"VALID".equals(s.context.inventoryStatus())?s.context.inventoryStatus():r.availableQuantity()==null?"UNKNOWN":"VALID"; boolean observed=Boolean.TRUE.equals(r.observationEligible());String quality=!"VALID".equals(s.context.inventoryStatus())?s.context.inventoryStatus():r.availableQuantity()==null?"UNKNOWN":"VALID";
String coverage=!Boolean.TRUE.equals(r.unitValid())?"UNIT_UNKNOWN_OR_CONFLICT":r.ownerOutbound()!=null?"COMPLETE":"COVERAGE_INSUFFICIENT"; String coverage=!Boolean.TRUE.equals(r.unitValid())?"UNIT_UNKNOWN_OR_CONFLICT":r.ownerOutbound()!=null?"COMPLETE":"COVERAGE_INSUFFICIENT";
var calculated=InventoryWindowPolicy.calculate(quality,r.availableQuantity(),r.outboundQuantity30d(),s.trusted, var calculated=InventoryWindowPolicy.calculate(quality,r.availableQuantity(),r.outboundQuantity30d(),s.trusted,
@@ -115,7 +118,7 @@ public class AnalyticsQueryService {
return new AnalyticsResponses.InventoryMetric(s.q.sourceAccount(),r.customerCode(),s.q.warehouseCode(),r.skuCode(),r.productName(),Boolean.TRUE.equals(r.unitValid())?"PCS":null, return new AnalyticsResponses.InventoryMetric(s.q.sourceAccount(),r.customerCode(),s.q.warehouseCode(),r.skuCode(),r.productName(),Boolean.TRUE.equals(r.unitValid())?"PCS":null,
r.orderCount(),r.physicalQuantity(),r.availableQuantity(),r.frozenQuantity(),r.transitQuantity(),utc(r.observedAtUtc()), r.orderCount(),r.physicalQuantity(),r.availableQuantity(),r.frozenQuantity(),r.transitQuantity(),utc(r.observedAtUtc()),
r.outboundQuantity30d()==null?null:r.outboundQuantity30d().divide(BigDecimal.valueOf(s.range.dayCount()),12,java.math.RoundingMode.HALF_UP),calculated.days(),quality,coverage,observed,utc(r.evidenceAtUtc()),calculated.status(),List.copyOf(new LinkedHashSet<>(reasons)), r.outboundQuantity30d()==null?null:r.outboundQuantity30d().divide(BigDecimal.valueOf(s.range.dayCount()),12,java.math.RoundingMode.HALF_UP),calculated.days(),quality,coverage,observed,utc(r.evidenceAtUtc()),calculated.status(),List.copyOf(new LinkedHashSet<>(reasons)),
null,r.defectivePhysicalQuantity(),r.outboundQuantity30d(),r.observedOutbound(),r.lastOutbound(),historyStart(s),List.copyOf(attention),Map.copyOf(statuses)); customerName,r.defectivePhysicalQuantity(),r.outboundQuantity30d(),r.observedOutbound(),r.lastOutbound(),historyStart(s),List.copyOf(attention),Map.copyOf(statuses));
} }
private static boolean positive(BigDecimal n){return n!=null&&n.signum()>0;} private static boolean positive(BigDecimal n){return n!=null&&n.signum()>0;}
private static LocalDate historyStart(Snapshot s){return SalesQueryCoverage.intervals(s.sales).stream() private static LocalDate historyStart(Snapshot s){return SalesQueryCoverage.intervals(s.sales).stream()
@@ -141,21 +144,26 @@ public class AnalyticsQueryService {
Snapshot s=context(q,false,false,hot,null); AnalyticsSqlRequest p=request(s,kind); Snapshot s=context(q,false,false,hot,null); AnalyticsSqlRequest p=request(s,kind);
List<AnalyticsReadMapper.RankingStat> statistics=mapper.rankingStats(p); List<AnalyticsReadMapper.RankingStat> statistics=mapper.rankingStats(p);
long total=statistics.isEmpty()?0:statistics.get(0).total(); long total=statistics.isEmpty()?0:statistics.get(0).total();
List<AnalyticsResponses.Metric> rows=q.offset()>=total?List.of():mapper.page(p).stream().map(r->metric(s,r,kind)).toList(); var names=ownerNames(s);List<AnalyticsResponses.Metric> rows=q.offset()>=total?List.of():mapper.page(p).stream().map(r->metric(s,r,kind,names.get(r.customerCode()))).toList();
Map<String,Long> excluded=new TreeMap<>(); Map<String,Long> excluded=new TreeMap<>();
for(var e:statistics)if(e.reason()!=null)excluded.put(e.reason(),e.excludedCount()); for(var e:statistics)if(e.reason()!=null)excluded.put(e.reason(),e.excludedCount());
return page(s,rows,total,excluded); return page(s,rows,total,excluded);
} }
private AnalyticsResponses.SalesSummary summary(Snapshot s,Aggregate r){ private Map<String,String> ownerNames(Snapshot s){return ownerDirectory.names(s.q.sourceAccount(),s.context.queryAsOf());}
public List<AnalyticsResponses.OwnerOption> ownerDirectory(AnalyticsQuery q){
Snapshot s=context(q,false,false,false,null);
return ownerNames(s).entrySet().stream().map(e->new AnalyticsResponses.OwnerOption(e.getKey(),e.getValue())).toList();
}
private AnalyticsResponses.SalesSummary summary(Snapshot s,Aggregate r,String customerName){
int complete=r.completeDayCount(),partial=r.partialDayCount(),unknown=r.unknownDayCount(); int complete=r.completeDayCount(),partial=r.partialDayCount(),unknown=r.unknownDayCount();
String status=complete==s.range.dayCount()?"COMPLETE":r.observedQuantity()!=null?"PARTIAL":"UNKNOWN"; String status=complete==s.range.dayCount()?"COMPLETE":r.observedQuantity()!=null?"PARTIAL":"UNKNOWN";
List<String> reasons=new ArrayList<>(s.context.reasonCodes()); List<String> reasons=new ArrayList<>(s.context.reasonCodes());
if(!Boolean.TRUE.equals(r.unitValid()))reasons.add("UNIT_UNKNOWN_OR_CONFLICT"); if(!Boolean.TRUE.equals(r.unitValid()))reasons.add("UNIT_UNKNOWN_OR_CONFLICT");
if(partial>0)reasons.add("SALES_DAY_INCOMPLETE"); if(unknown>0)reasons.add("SALES_NOT_COVERED_OR_SKU_HISTORY_UNKNOWN"); if(partial>0)reasons.add("SALES_DAY_INCOMPLETE"); if(unknown>0)reasons.add("SALES_NOT_COVERED_OR_SKU_HISTORY_UNKNOWN");
return new AnalyticsResponses.SalesSummary(s.q.sourceAccount(),s.q.warehouseCode(),r.customerCode(),r.skuCode(),r.productName(), return new AnalyticsResponses.SalesSummary(s.q.sourceAccount(),s.q.warehouseCode(),r.customerCode(),r.skuCode(),r.productName(),
r.quantityUnit(),"COMPLETE".equals(status)?r.observedQuantity():null,r.observedQuantity(),status,List.copyOf(reasons),complete,partial,unknown); r.quantityUnit(),"COMPLETE".equals(status)?r.observedQuantity():null,r.observedQuantity(),status,List.copyOf(reasons),complete,partial,unknown,customerName);
} }
private AnalyticsResponses.Metric metric(Snapshot s,Aggregate r,String kind){ private AnalyticsResponses.Metric metric(Snapshot s,Aggregate r,String kind,String customerName){
Instant evidence=utc(r.evidenceAtUtc()); Instant evidence=utc(r.evidenceAtUtc());
boolean observed=evidence!=null&&!evidence.isAfter(s.range.startDate().atStartOfDay(BUSINESS_ZONE).toInstant()); boolean observed=evidence!=null&&!evidence.isAfter(s.range.startDate().atStartOfDay(BUSINESS_ZONE).toInstant());
boolean stockValid="VALID".equals(r.inventoryQuality()); boolean stockValid="VALID".equals(r.inventoryQuality());
@@ -178,7 +186,7 @@ public class AnalyticsQueryService {
return new AnalyticsResponses.Metric(s.q.sourceAccount(),r.customerCode(),s.q.warehouseCode(),r.skuCode(),r.productName(),r.quantityUnit(), return new AnalyticsResponses.Metric(s.q.sourceAccount(),r.customerCode(),s.q.warehouseCode(),r.skuCode(),r.productName(),r.quantityUnit(),
r.windowQuantity(),r.windowSkuOrderCount(),today,stockValid?r.physicalQuantity():null,stockValid?r.availableQuantity():null, r.windowQuantity(),r.windowSkuOrderCount(),today,stockValid?r.physicalQuantity():null,stockValid?r.availableQuantity():null,
stockValid?r.frozenQuantity():null,stockValid?r.transitQuantity():null,utc(r.inventoryObservedAtUtc()),result.average(),result.days(), stockValid?r.frozenQuantity():null,stockValid?r.transitQuantity():null,utc(r.inventoryObservedAtUtc()),result.average(),result.days(),
r.inventoryQuality(),coverage,observed,evidence,result.status(),List.copyOf(new LinkedHashSet<>(reasons)),null,null,r.sales30Quantity(),null,historyStart(s),List.of(),Map.of()); r.inventoryQuality(),coverage,observed,evidence,result.status(),List.copyOf(new LinkedHashSet<>(reasons)),customerName,null,r.sales30Quantity(),null,historyStart(s),List.of(),Map.of());
} }
Snapshot context(AnalyticsQuery q,boolean chart,boolean exact,boolean hot,AnalyticsInventoryOptions inventoryOptions){ Snapshot context(AnalyticsQuery q,boolean chart,boolean exact,boolean hot,AnalyticsInventoryOptions inventoryOptions){
boolean dashboardWindow=inventoryOptions!=null&&"DASHBOARD".equals(inventoryOptions.drilldownFrom()); boolean dashboardWindow=inventoryOptions!=null&&"DASHBOARD".equals(inventoryOptions.drilldownFrom());
@@ -17,7 +17,7 @@ public final class AnalyticsResponses {
public record Page<T>(Context context,List<T> records,int current,int size,long total,long pages,Map<String,Long> excludedReasons){} public record Page<T>(Context context,List<T> records,int current,int size,long total,long pages,Map<String,Long> excludedReasons){}
public record SalesSummary(String sourceAccount,String warehouseCode,String customerCode,String sku,String productName, public record SalesSummary(String sourceAccount,String warehouseCode,String customerCode,String sku,String productName,
String quantityUnit,BigDecimal outboundQuantity,BigDecimal observedQuantity,String dataStatus,List<String> reasonCodes, String quantityUnit,BigDecimal outboundQuantity,BigDecimal observedQuantity,String dataStatus,List<String> reasonCodes,
int completeDayCount,int partialDayCount,int unknownDayCount){} int completeDayCount,int partialDayCount,int unknownDayCount,String customerName){}
public record Point(LocalDate businessDate,BigDecimal outboundQuantity,BigDecimal observedQuantity,String dataStatus, public record Point(LocalDate businessDate,BigDecimal outboundQuantity,BigDecimal observedQuantity,String dataStatus,
List<String> reasonCodes,String quantityUnit,Instant coveredFrom,Instant coveredUntil){} List<String> reasonCodes,String quantityUnit,Instant coveredFrom,Instant coveredUntil){}
public record Trend(Context context,SalesSummary summary,List<Point> points){} public record Trend(Context context,SalesSummary summary,List<Point> points){}
@@ -39,7 +39,7 @@ public interface CollectionReadMapper {
+ " AND JSON_TYPE(JSON_EXTRACT(t.query_scope_json,'$.warehouses[0]'))='STRING')"; + " AND JSON_TYPE(JSON_EXTRACT(t.query_scope_json,'$.warehouses[0]'))='STRING')";
String TASK_WAREHOUSE = "CASE WHEN " + VALIDATION_ROOT String TASK_WAREHOUSE = "CASE WHEN " + VALIDATION_ROOT
+ " THEN JSON_UNQUOTE(JSON_EXTRACT(t.query_scope_json,'$.warehouses[0]')) ELSE t.warehouse_code END"; + " THEN JSON_UNQUOTE(JSON_EXTRACT(t.query_scope_json,'$.warehouses[0]')) ELSE t.warehouse_code END";
String TASK_FAMILY = "CASE WHEN t.task_role='ACCOUNT_DISCOVERY' THEN 'OUTBOUND' ELSE s.data_family END"; String TASK_FAMILY = "CASE WHEN t.task_type='MASTER_DATA' THEN 'MASTER_DATA' WHEN t.task_role='ACCOUNT_DISCOVERY' THEN 'OUTBOUND' ELSE s.data_family END";
String TASK_MEMBERSHIP = "((t.task_role='WAREHOUSE_SYNC' AND s.id IS NOT NULL) OR " + VALIDATION_ROOT + ")"; String TASK_MEMBERSHIP = "((t.task_role='WAREHOUSE_SYNC' AND s.id IS NOT NULL) OR " + VALIDATION_ROOT + ")";
String BUSINESS_LOCAL_WORK_READY = "CASE WHEN t.task_role='WAREHOUSE_SYNC' AND s.data_family='OUTBOUND'" String BUSINESS_LOCAL_WORK_READY = "CASE WHEN t.task_role='WAREHOUSE_SYNC' AND s.data_family='OUTBOUND'"
+ " AND (t.task_type='REBUILD' OR t.apply_state='APPLIED' OR (" + " AND (t.task_type='REBUILD' OR t.apply_state='APPLIED' OR ("
@@ -14,10 +14,12 @@ import java.util.*;
public class DashboardQueryService { public class DashboardQueryService {
private final AnalyticsQueryService contextService; private final AnalyticsQueryService contextService;
private final DashboardReadMapper mapper; private final DashboardReadMapper mapper;
public DashboardQueryService(AnalyticsQueryService contextService,DashboardReadMapper mapper){this.contextService=contextService;this.mapper=mapper;} private final SelfopOwnerDirectory ownerDirectory;
public DashboardQueryService(AnalyticsQueryService contextService,DashboardReadMapper mapper,SelfopOwnerDirectory ownerDirectory){this.contextService=contextService;this.mapper=mapper;this.ownerDirectory=ownerDirectory;}
public DashboardResponses.Dashboard dashboard(AnalyticsQuery input){ public DashboardResponses.Dashboard dashboard(AnalyticsQuery input){
AnalyticsQuery q=DashboardRequest.normalize(input); AnalyticsQuery q=DashboardRequest.normalize(input);
var snapshot=contextService.context(q,true,false,false,null);var c=snapshot.context(); var snapshot=contextService.context(q,true,false,false,null);var c=snapshot.context();
var names=ownerDirectory.names(q.sourceAccount(),c.queryAsOf());
if(snapshot.sales()!=null&&!Boolean.TRUE.equals(snapshot.sales().warehouseDailyReady())) if(snapshot.sales()!=null&&!Boolean.TRUE.equals(snapshot.sales().warehouseDailyReady()))
throw new com.baiyue.common.core.exception.BizException("该销量版本尚未生成仓库每日汇总,请重新发布统计后再查询"); throw new com.baiyue.common.core.exception.BizException("该销量版本尚未生成仓库每日汇总,请重新发布统计后再查询");
var base=contextService.request(snapshot,"DASHBOARD");LocalDate windowFrom=c.rangeStartDate(),windowUntil=c.rangeEndDateExclusive(); var base=contextService.request(snapshot,"DASHBOARD");LocalDate windowFrom=c.rangeStartDate(),windowUntil=c.rangeEndDateExclusive();
@@ -38,7 +40,7 @@ public class DashboardQueryService {
hotStats=hotReport.get(0);hotRanked=hotReport.stream().filter(row->row.get("rank_code")!=null).map(DashboardQueryService::ranked).toList(); hotStats=hotReport.get(0);hotRanked=hotReport.stream().filter(row->row.get("rank_code")!=null).map(DashboardQueryService::ranked).toList();
} }
var salesSummary=new AnalyticsResponses.SalesSummary(q.sourceAccount(),q.warehouseCode(),null,null,null,"PCS",trendSummary.outboundQuantity(),trendSummary.observedQuantity(), var salesSummary=new AnalyticsResponses.SalesSummary(q.sourceAccount(),q.warehouseCode(),null,null,null,"PCS",trendSummary.outboundQuantity(),trendSummary.observedQuantity(),
trendSummary.dataStatus(),trendSummary.reasonCodes(),trendSummary.completeDays(),trendSummary.partialDays(),trendSummary.unknownDays()); trendSummary.dataStatus(),trendSummary.reasonCodes(),trendSummary.completeDays(),trendSummary.partialDays(),trendSummary.unknownDays(),null);
var points=trendPoints.stream().map(t->new AnalyticsResponses.Point(t.date(),t.outboundQuantity(),t.observedQuantity(),t.dataStatus(),t.reasonCodes(),"PCS",t.provedFrom(),t.provedUntil())).toList(); var points=trendPoints.stream().map(t->new AnalyticsResponses.Point(t.date(),t.outboundQuantity(),t.observedQuantity(),t.dataStatus(),t.reasonCodes(),"PCS",t.provedFrom(),t.provedUntil())).toList();
var interval=new DashboardResponses.Metric(trendSummary.outboundQuantity(),trendSummary.observedQuantity(),"PCS",trendSummary.dataStatus(),null,trendSummary.reasonCodes(),trendSummary.outboundQuantity(),trendSummary.observedQuantity()); var interval=new DashboardResponses.Metric(trendSummary.outboundQuantity(),trendSummary.observedQuantity(),"PCS",trendSummary.dataStatus(),null,trendSummary.reasonCodes(),trendSummary.outboundQuantity(),trendSummary.observedQuantity());
// Partial sales never become a formal daily average for the selected window. // Partial sales never become a formal daily average for the selected window.
@@ -51,14 +53,14 @@ public class DashboardQueryService {
Instant freshUntil=contextService.inventoryFreshUntil(snapshot),drilldownUntil=CoverageDistributionPolicy.expiresAt(c,freshUntil); Instant freshUntil=contextService.inventoryFreshUntil(snapshot),drilldownUntil=CoverageDistributionPolicy.expiresAt(c,freshUntil);
return new DashboardResponses.Dashboard(new DashboardResponses.Context(c,freshUntil,drilldownUntil),kpis, return new DashboardResponses.Dashboard(new DashboardResponses.Context(c,freshUntil,drilldownUntil),kpis,
new DashboardResponses.SalesTrend(salesSummary,points),distribution(stats,inventoryAvailable,c), new DashboardResponses.SalesTrend(salesSummary,points),distribution(stats,inventoryAvailable,c),
hotRanking(ranking("HOT",hotStats,hotRanked,base.salesRun()>0,c.rangeStartDate(),c.rangeEndDateExclusive(),c),trendSummary.dataStatus()), hotRanking(ranking("HOT",hotStats,hotRanked,base.salesRun()>0,c.rangeStartDate(),c.rangeEndDateExclusive(),c,names),trendSummary.dataStatus()),
ranking("LONG",stats,ranked,inventoryAvailable,windowFrom,windowUntil,c), ranking("LONG",stats,ranked,inventoryAvailable,windowFrom,windowUntil,c,names),
ranking("NO_OUTBOUND",stats,ranked,inventoryAvailable,windowFrom,windowUntil,c)); ranking("NO_OUTBOUND",stats,ranked,inventoryAvailable,windowFrom,windowUntil,c,names));
} }
private static DashboardResponses.Ranking hotRanking(DashboardResponses.Ranking ranking,String windowStatus){ private static DashboardResponses.Ranking hotRanking(DashboardResponses.Ranking ranking,String windowStatus){
if("COMPLETE".equals(windowStatus))return ranking; if("COMPLETE".equals(windowStatus))return ranking;
var items=ranking.items().stream().map(i->new DashboardResponses.Item(i.customerCode(),i.sku(),i.productName(),i.value(),i.unit(),i.windowOutboundQuantity(),i.skuOutboundOrderCount(), var items=ranking.items().stream().map(i->new DashboardResponses.Item(i.customerCode(),i.sku(),i.productName(),i.value(),i.unit(),i.windowOutboundQuantity(),i.skuOutboundOrderCount(),
i.physicalQuantity(),i.availableQuantity(),i.frozenQuantity(),i.defectivePhysicalQuantity(),null,null)).toList(); i.physicalQuantity(),i.availableQuantity(),i.frozenQuantity(),i.defectivePhysicalQuantity(),null,null,i.customerName())).toList();
var reasons=new LinkedHashSet<>(ranking.reasonCodes());reasons.add("SALES_WINDOW_INCOMPLETE"); var reasons=new LinkedHashSet<>(ranking.reasonCodes());reasons.add("SALES_WINDOW_INCOMPLETE");
return new DashboardResponses.Ranking(ranking.windowStartDate(),ranking.windowEndDateExclusive(),ranking.calculationWindowDays(),ranking.eligibleCount(),ranking.unassessableCount(),items, return new DashboardResponses.Ranking(ranking.windowStartDate(),ranking.windowEndDateExclusive(),ranking.calculationWindowDays(),ranking.eligibleCount(),ranking.unassessableCount(),items,
items.isEmpty()&&"UNKNOWN".equals(windowStatus)?"UNKNOWN":"PARTIAL",List.copyOf(reasons)); items.isEmpty()&&"UNKNOWN".equals(windowStatus)?"UNKNOWN":"PARTIAL",List.copyOf(reasons));
@@ -86,7 +88,7 @@ public class DashboardQueryService {
return new DashboardResponses.Distribution(CoverageDistributionPolicy.RULE_VERSION,available?count(s,"candidate_skus"):null, return new DashboardResponses.Distribution(CoverageDistributionPolicy.RULE_VERSION,available?count(s,"candidate_skus"):null,
available?count(s,"excluded_zero_skus"):null,List.copyOf(buckets),status,codes,available?Map.copyOf(reasons):Map.of()); available?count(s,"excluded_zero_skus"):null,List.copyOf(buckets),status,codes,available?Map.copyOf(reasons):Map.of());
} }
private static DashboardResponses.Ranking ranking(String kind,Map<String,Object> s,List<DashboardReadMapper.Ranked> rows,boolean available,LocalDate from,LocalDate until,AnalyticsResponses.Context c){ private static DashboardResponses.Ranking ranking(String kind,Map<String,Object> s,List<DashboardReadMapper.Ranked> rows,boolean available,LocalDate from,LocalDate until,AnalyticsResponses.Context c,Map<String,String> names){
int windowDays=(int)java.time.temporal.ChronoUnit.DAYS.between(from,until); int windowDays=(int)java.time.temporal.ChronoUnit.DAYS.between(from,until);
String prefix=switch(kind){case "HOT"->"hot";case "LONG"->"long";default->"no_outbound";}; String prefix=switch(kind){case "HOT"->"hot";case "LONG"->"long";default->"no_outbound";};
List<DashboardReadMapper.Ranked> selected=rows.stream().filter(r->kind.equals(r.rankCode())).sorted(Comparator List<DashboardReadMapper.Ranked> selected=rows.stream().filter(r->kind.equals(r.rankCode())).sorted(Comparator
@@ -95,7 +97,7 @@ public class DashboardQueryService {
.thenComparing(DashboardReadMapper.Ranked::skuCode).thenComparing(DashboardReadMapper.Ranked::customerCode)).toList(); .thenComparing(DashboardReadMapper.Ranked::skuCode).thenComparing(DashboardReadMapper.Ranked::customerCode)).toList();
var items=available?selected.stream().map(r->new DashboardResponses.Item(r.customerCode(),r.skuCode(),r.productName(),r.mainValue(),"LONG".equals(kind)?"DAY":"PCS", var items=available?selected.stream().map(r->new DashboardResponses.Item(r.customerCode(),r.skuCode(),r.productName(),r.mainValue(),"LONG".equals(kind)?"DAY":"PCS",
"HOT".equals(kind)?r.ownerOutbound():r.outboundQuantity30d(),r.orderCount(),r.physicalQuantity(),r.availableQuantity(),r.frozenQuantity(),r.defectivePhysicalQuantity(), "HOT".equals(kind)?r.ownerOutbound():r.outboundQuantity30d(),r.orderCount(),r.physicalQuantity(),r.availableQuantity(),r.frozenQuantity(),r.defectivePhysicalQuantity(),
divide("HOT".equals(kind)?r.ownerOutbound():r.outboundQuantity30d(),windowDays),"HOT".equals(kind)?null:r.coverageDays())).toList():List.<DashboardResponses.Item>of(); divide("HOT".equals(kind)?r.ownerOutbound():r.outboundQuantity30d(),windowDays),"HOT".equals(kind)?null:r.coverageDays(),names.get(r.customerCode()))).toList():List.<DashboardResponses.Item>of();
long unknown=count(s,prefix+"_unknown");String status=!available?"UNKNOWN":unknown>0?"PARTIAL":"COMPLETE"; long unknown=count(s,prefix+"_unknown");String status=!available?"UNKNOWN":unknown>0?"PARTIAL":"COMPLETE";
List<String> reasons=!available?List.of("HOT".equals(kind)?"SALES_UNAVAILABLE":"INVENTORY_UNAVAILABLE"):unknown>0?List.of("UNASSESSABLE_SKUS"):List.of(); List<String> reasons=!available?List.of("HOT".equals(kind)?"SALES_UNAVAILABLE":"INVENTORY_UNAVAILABLE"):unknown>0?List.of("UNASSESSABLE_SKUS"):List.of();
if(available&&count(s,prefix+"_eligible")==0&&unknown==0)reasons=List.of("NO_ELIGIBLE_SKUS"); if(available&&count(s,prefix+"_eligible")==0&&unknown==0)reasons=List.of("NO_ELIGIBLE_SKUS");
@@ -20,7 +20,7 @@ public final class DashboardResponses {
List<Bucket> buckets,String dataStatus,List<String> reasonCodes,Map<String,Long> unknownReasons){} List<Bucket> buckets,String dataStatus,List<String> reasonCodes,Map<String,Long> unknownReasons){}
public record Item(String customerCode,String sku,String productName,BigDecimal value,String unit,BigDecimal windowOutboundQuantity, public record Item(String customerCode,String sku,String productName,BigDecimal value,String unit,BigDecimal windowOutboundQuantity,
Long skuOutboundOrderCount,BigDecimal physicalQuantity,BigDecimal availableQuantity,BigDecimal frozenQuantity, Long skuOutboundOrderCount,BigDecimal physicalQuantity,BigDecimal availableQuantity,BigDecimal frozenQuantity,
BigDecimal defectivePhysicalQuantity,BigDecimal averageDailyOutboundQuantity,BigDecimal inventoryCoverageDays){} BigDecimal defectivePhysicalQuantity,BigDecimal averageDailyOutboundQuantity,BigDecimal inventoryCoverageDays,String customerName){}
public record Ranking(LocalDate windowStartDate,LocalDate windowEndDateExclusive,int calculationWindowDays,Long eligibleCount, public record Ranking(LocalDate windowStartDate,LocalDate windowEndDateExclusive,int calculationWindowDays,Long eligibleCount,
Long unassessableCount,List<Item> items,String dataStatus,List<String> reasonCodes){} Long unassessableCount,List<Item> items,String dataStatus,List<String> reasonCodes){}
public record Dashboard(Context context,Kpis kpis,SalesTrend salesTrend,Distribution coverageDistribution, public record Dashboard(Context context,Kpis kpis,SalesTrend salesTrend,Distribution coverageDistribution,
@@ -18,11 +18,13 @@ public class InventoryTurnoverContext {
private final InventoryTurnoverReadMapper mapper; private final InventoryTurnoverReadMapper mapper;
private final ObjectMapper json; private final ObjectMapper json;
private final Clock clock; private final Clock clock;
@Autowired public InventoryTurnoverContext(AnalyticsReadMapper analytics,InventoryTurnoverReadMapper mapper,ObjectMapper json) { private final SelfopOwnerDirectory ownerDirectory;
this(analytics,mapper,json,Clock.systemUTC()); @Autowired public InventoryTurnoverContext(AnalyticsReadMapper analytics,InventoryTurnoverReadMapper mapper,ObjectMapper json,SelfopOwnerDirectory ownerDirectory) {
this(analytics,mapper,json,Clock.systemUTC(),ownerDirectory);
} }
InventoryTurnoverContext(AnalyticsReadMapper analytics,InventoryTurnoverReadMapper mapper,ObjectMapper json,Clock clock) { InventoryTurnoverContext(AnalyticsReadMapper analytics,InventoryTurnoverReadMapper mapper,ObjectMapper json,Clock clock,SelfopOwnerDirectory ownerDirectory) {
this.analytics=analytics;this.mapper=mapper;this.json=json;this.clock=clock; this.analytics=analytics;this.mapper=mapper;this.json=json;this.clock=clock;
this.ownerDirectory=ownerDirectory;
} }
public record Evidence(String account,String warehouse,LocalDate end,int days,Instant asOf, public record Evidence(String account,String warehouse,LocalDate end,int days,Instant asOf,
Long salesRun,Long inventoryRun,String salesHash,String inventoryHash,boolean rebuild, Long salesRun,Long inventoryRun,String salesHash,String inventoryHash,boolean rebuild,
@@ -41,7 +43,7 @@ public class InventoryTurnoverContext {
var aq=new AnalyticsQuery(q.sourceAccount(),q.warehouseCode(),null,null,"CUSTOM",end.minusDays(Math.max(30,q.days())-1),end,"DAY",null,1,20, var aq=new AnalyticsQuery(q.sourceAccount(),q.warehouseCode(),null,null,"CUSTOM",end.minusDays(Math.max(30,q.days())-1),end,"DAY",null,1,20,
sales==null?0L:sales.id(),stock==null?0L:stock.id(),sales==null?null:sales.scopeHash(),stock==null?null:stock.scopeHash(),asOf); sales==null?0L:sales.id(),stock==null?0L:stock.id(),sales==null?null:sales.scopeHash(),stock==null?null:stock.scopeHash(),asOf);
// Staleness is evaluated at queryAsOf, so pagination does not age a fixed snapshot midway. // Staleness is evaluated at queryAsOf, so pagination does not age a fixed snapshot midway.
var snapshot=new AnalyticsQueryService(analytics,json,Clock.fixed(asOf,ZoneOffset.UTC)).context(aq,true,false,false,null); var snapshot=new AnalyticsQueryService(analytics,json,Clock.fixed(asOf,ZoneOffset.UTC),ownerDirectory).context(aq,true,false,false,null);
String inventoryHash=scopes.stream().filter(s->"INVENTORY".equals(s.dataFamily())).map(AnalyticsReadRows.Scope::scopeHash).findFirst().orElse(null); String inventoryHash=scopes.stream().filter(s->"INVENTORY".equals(s.dataFamily())).map(AnalyticsReadRows.Scope::scopeHash).findFirst().orElse(null);
var mapping=new TreeMap<LocalDate,Long>(); var mapping=new TreeMap<LocalDate,Long>();
for(var sample:mapper.samples(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf),end.minusDays(Math.max(30,q.days())-1),end.plusDays(1))) { for(var sample:mapper.samples(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf),end.minusDays(Math.max(30,q.days())-1),end.plusDays(1))) {
@@ -65,7 +65,7 @@ public final class InventoryTurnoverPolicy {
if(decimal(r,"defective")!=null&&decimal(r,"defective").signum()>0)attention.add("DEFECTIVE_STOCK"); if(decimal(r,"defective")!=null&&decimal(r,"defective").signum()>0)attention.add("DEFECTIVE_STOCK");
if(count(r,"unassessable")>0)attention.add("UNASSESSABLE"); if(count(r,"unassessable")>0)attention.add("UNASSESSABLE");
String last=text(r,"last_outbound"); String last=text(r,"last_outbound");
return new Row(text(r,"customer_code"),null,owner?null:text(r,"sku_code"),owner?null:text(r,"product_name"),physical,available, return new Row(text(r,"customer_code"),text(r,"customer_name"),owner?null:text(r,"sku_code"),owner?null:text(r,"product_name"),physical,available,
total(r,"frozen",owner),total(r,"defective",owner),total(r,"transit",owner),owner?decimal(r,"physical_skus"):physical.value()==null?null:physical.value().signum()>0?BigDecimal.ONE:BigDecimal.ZERO, total(r,"frozen",owner),total(r,"defective",owner),total(r,"transit",owner),owner?decimal(r,"physical_skus"):physical.value()==null?null:physical.value().signum()>0?BigDecimal.ONE:BigDecimal.ZERO,
windows,base.averageDailyOutboundQuantity(),coverage,coverageStatus,average,turnover,sampleStatus,sampleCount,q.days(),List.copyOf(missing), windows,base.averageDailyOutboundQuantity(),coverage,coverageStatus,average,turnover,sampleStatus,sampleCount,q.days(),List.copyOf(missing),
"VALID".equals(sampleStatus)?List.of():List.of(sampleStatus),last==null?null:LocalDate.parse(last.substring(0,10)),List.copyOf(attention), "VALID".equals(sampleStatus)?List.of():List.of(sampleStatus),last==null?null:LocalDate.parse(last.substring(0,10)),List.copyOf(attention),
@@ -16,9 +16,11 @@ public class InventoryTurnoverQueryService {
private final InventoryTurnoverContext contexts; private final InventoryTurnoverContext contexts;
private final InventoryTurnoverReadMapper mapper; private final InventoryTurnoverReadMapper mapper;
private final ObjectMapper json; private final ObjectMapper json;
public InventoryTurnoverQueryService(InventoryTurnoverContext contexts,InventoryTurnoverReadMapper mapper,ObjectMapper json){this.contexts=contexts;this.mapper=mapper;this.json=json;} private final SelfopOwnerDirectory ownerDirectory;
public InventoryTurnoverQueryService(InventoryTurnoverContext contexts,InventoryTurnoverReadMapper mapper,ObjectMapper json,SelfopOwnerDirectory ownerDirectory){this.contexts=contexts;this.mapper=mapper;this.json=json;this.ownerDirectory=ownerDirectory;}
public Response query(InventoryTurnoverRequest request) { public Response query(InventoryTurnoverRequest request) {
var frozen=contexts.resolve(request);var input=InventoryTurnoverSql.input(request,frozen);var context=frozen.response(); var frozen=contexts.resolve(request);var input=InventoryTurnoverSql.input(request,frozen);var context=frozen.response();
var names=ownerDirectory.names(request.sourceAccount(),context.queryAsOf());
long total=0;boolean hasTotal=false;Overview summary=null;Charts charts=null; long total=0;boolean hasTotal=false;Overview summary=null;Charts charts=null;
List<Row> rows=new ArrayList<>();List<OwnerPhysical> owners=new ArrayList<>();int unknown=0; List<Row> rows=new ArrayList<>();List<OwnerPhysical> owners=new ArrayList<>();int unknown=0;
for(var part:mapper.report(input)) { for(var part:mapper.report(input)) {
@@ -27,11 +29,13 @@ public class InventoryTurnoverQueryService {
value=json.readerFor(new TypeReference<Map<String,Object>>(){}).with(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) value=json.readerFor(new TypeReference<Map<String,Object>>(){}).with(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
.readValue(text(envelope,"payload")); .readValue(text(envelope,"payload"));
} catch(Exception e){throw new IllegalStateException("Invalid turnover query projection",e);} } catch(Exception e){throw new IllegalStateException("Invalid turnover query projection",e);}
var customerCode=text(value,"customer_code");
if(customerCode!=null)value.put("customer_name",names.get(customerCode));
switch(text(envelope,"section")) { switch(text(envelope,"section")) {
case "ROW" -> rows.add(row(value,request,context)); case "ROW" -> rows.add(row(value,request,context));
case "TOTAL" -> {total=decimal(value,"total").longValueExact();hasTotal=true;} case "TOTAL" -> {total=decimal(value,"total").longValueExact();hasTotal=true;}
case "OVERVIEW" -> summary=overview(value,context); case "OVERVIEW" -> summary=overview(value,context);
case "OWNER" -> owners.add(new OwnerPhysical(text(value,"customer_code"),null,decimal(value,"physical"))); case "OWNER" -> owners.add(new OwnerPhysical(text(value,"customer_code"),text(value,"customer_name"),decimal(value,"physical")));
case "OWNER_META" -> unknown=count(value,"unknown_count"); case "OWNER_META" -> unknown=count(value,"unknown_count");
default -> throw new IllegalStateException("Unknown turnover query section"); default -> throw new IllegalStateException("Unknown turnover query section");
} }
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopMasterDataMapper;
import org.springframework.stereotype.Service;
import java.time.*;
import java.util.*;
/** Names are read from the last complete master-data batch visible at the query's fixed time. */
@Service
public class SelfopOwnerDirectory {
private final SelfopMasterDataMapper mapper;
public SelfopOwnerDirectory(SelfopMasterDataMapper mapper){this.mapper=mapper;}
public Map<String,String> names(String account,Instant asOf) {
Long run=mapper.publishedAt(account,LocalDateTime.ofInstant(asOf,ZoneOffset.UTC));
if(run==null)return Map.of();
var result=new LinkedHashMap<String,String>();
for(var owner:mapper.owners(account,run))result.put(owner.customerCode(),owner.customerName()==null||owner.customerName().isBlank()?null:owner.customerName());
return Collections.unmodifiableMap(result);
}
}
@@ -7,7 +7,7 @@ public final class SelfopEgressDtos {
boolean continuousEnabled,String sourceAccountFingerprint,boolean grantPresent,String grantFingerprint, boolean continuousEnabled,String sourceAccountFingerprint,boolean grantPresent,String grantFingerprint,
long intervalMs,int permitMaxSeconds,boolean executionEnabled,int pendingMaxSeconds, long intervalMs,int permitMaxSeconds,boolean executionEnabled,int pendingMaxSeconds,
boolean exclusiveHandoffVerified,boolean priorEgressClosed,boolean inflightDrained,boolean transferredBudgetVerified){} boolean exclusiveHandoffVerified,boolean priorEgressClosed,boolean inflightDrained,boolean transferredBudgetVerified){}
public enum Operation {WAREHOUSES,OUTBOUND_QUERY,OUTBOUND_LOCATE,OUTBOUND_DETAIL,PRODUCT_INVENTORY} public enum Operation {WAREHOUSES,OUTBOUND_QUERY,OUTBOUND_LOCATE,OUTBOUND_DETAIL,PRODUCT_INVENTORY,CUSTOMERS,PRODUCTS}
public record EgressContext(String sourceAccount,String executionPurpose,String rootTaskId,String sessionId,String attemptId,String exclusiveGrantId){} public record EgressContext(String sourceAccount,String executionPurpose,String rootTaskId,String sessionId,String attemptId,String exclusiveGrantId){}
@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS) @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS)
public record AuthorizationDenial(String errorCode,boolean sampleDone,Instant waitUntil){} public record AuthorizationDenial(String errorCode,boolean sampleDone,Instant waitUntil){}
@@ -39,6 +39,9 @@ public class SelfopEgressService {
SelfopBusinessBudget business=hasBusinessSession(root)?business(root):null; SelfopBusinessBudget business=hasBusinessSession(root)?business(root):null;
if(business!=null){verifyBusinessTask(task,root);String limit=business.limit(now);if(limit!=null){stopBusiness(root,business,limit,now);throw reject(limit);}} if(business!=null){verifyBusinessTask(task,root);String limit=business.limit(now);if(limit!=null){stopBusiness(root,business,limit,now);throw reject(limit);}}
verify(task,run,root,account,now); verify(task,run,root,account,now);
boolean masterOperation=operation==Operation.CUSTOMERS||operation==Operation.PRODUCTS;
if(masterOperation!=("MASTER_DATA".equals(task.getTaskType()))
||masterOperation&&(!"BUSINESS".equals(task.getExecutionPurpose())||pageSize<1||pageSize>(operation==Operation.CUSTOMERS?1000:100)))throw reject("EGRESS_OPERATION_TASK_MISMATCH");
checkAccount(account,budget,now); checkAccount(account,budget,now);
SelfopValidationBudget validation=null; SelfopValidationBudget validation=null;
if("VALIDATION".equals(context.executionPurpose())){ if("VALIDATION".equals(context.executionPurpose())){
@@ -17,6 +17,7 @@ public class SelfopExecutionService {
private final SelfopLeaseService leases; private final SelfopLeaseService leases;
private final SelfopDiscoveryService discovery; private final SelfopDiscoveryService discovery;
private final SelfopInventoryCollector stock; private final SelfopInventoryCollector stock;
private final SelfopMasterDataCollector masterData;
private final InventorySnapshotService snapshots; private final InventorySnapshotService snapshots;
private final OutboundFactService facts; private final OutboundFactService facts;
private final OutboundCandidateBuilder candidates; private final OutboundCandidateBuilder candidates;
@@ -31,9 +32,10 @@ public class SelfopExecutionService {
public SelfopExecutionService(SelfopAnalyticsProperties config,SelfopTaskService taskService,SelfopLeaseService leases, public SelfopExecutionService(SelfopAnalyticsProperties config,SelfopTaskService taskService,SelfopLeaseService leases,
SelfopDiscoveryService discovery,SelfopInventoryCollector stock,InventorySnapshotService snapshots,OutboundFactService facts, SelfopDiscoveryService discovery,SelfopInventoryCollector stock,InventorySnapshotService snapshots,OutboundFactService facts,
OutboundCandidateBuilder candidates,SelfopSalesPublisher publisher,SelfopSyncScopeMapper scopes,SelfopSyncTaskMapper tasks, OutboundCandidateBuilder candidates,SelfopSalesPublisher publisher,SelfopSyncScopeMapper scopes,SelfopSyncTaskMapper tasks,
SelfopSyncStageMapper stages,SelfopOutboundOrderMapper orders,SelfopReferenceRecovery recovery,SelfopValidationRunner validation,SelfopSyncRunMapper runs){ SelfopSyncStageMapper stages,SelfopOutboundOrderMapper orders,SelfopReferenceRecovery recovery,SelfopValidationRunner validation,SelfopSyncRunMapper runs,SelfopMasterDataCollector masterData){
this.config=config;this.taskService=taskService;this.leases=leases;this.discovery=discovery;this.stock=stock;this.snapshots=snapshots; this.config=config;this.taskService=taskService;this.leases=leases;this.discovery=discovery;this.stock=stock;this.snapshots=snapshots;
this.facts=facts;this.candidates=candidates;this.publisher=publisher;this.scopes=scopes;this.tasks=tasks;this.stages=stages;this.orders=orders;this.recovery=recovery;this.validation=validation;this.runs=runs; this.facts=facts;this.candidates=candidates;this.publisher=publisher;this.scopes=scopes;this.tasks=tasks;this.stages=stages;this.orders=orders;this.recovery=recovery;this.validation=validation;this.runs=runs;
this.masterData=masterData;
} }
public void execute(long taskId){ public void execute(long taskId){
var task=tasks.selectById(taskId);require(task!=null,"SELFOP_TASK_MISSING");SelfopCollectionPolicy.requireExecution(config,task.getExecutionPurpose()); var task=tasks.selectById(taskId);require(task!=null,"SELFOP_TASK_MISSING");SelfopCollectionPolicy.requireExecution(config,task.getExecutionPurpose());
@@ -63,7 +65,8 @@ public class SelfopExecutionService {
task=tasks.selectById(taskId); task=tasks.selectById(taskId);
if("VALIDATION".equals(task.getExecutionPurpose())){validation.run(task,run,primary);return;} if("VALIDATION".equals(task.getExecutionPurpose())){validation.run(task,run,primary);return;}
if(!primary.account()&&"OUTBOUND".equals(scopes.selectById(task.getScopeId()).getDataFamily()))recovery.refresh(task,held); if(!primary.account()&&"OUTBOUND".equals(scopes.selectById(task.getScopeId()).getDataFamily()))recovery.refresh(task,held);
if(primary.account())discovery.collect(task,run,primary); if("MASTER_DATA".equals(task.getTaskType()))masterData.collect(task,run,primary);
else if(primary.account())discovery.collect(task,run,primary);
else if("INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())){ else if("INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())){
if(stock.collect(task,run,primary)){snapshots.build(task.getScopeId(),run.getId(),owner,primary.fence());snapshots.publish(task.getScopeId(),run.getId(),owner,primary.fence());} if(stock.collect(task,run,primary)){snapshots.build(task.getScopeId(),run.getId(),owner,primary.fence());snapshots.publish(task.getScopeId(),run.getId(),owner,primary.fence());}
}else{ }else{
@@ -0,0 +1,85 @@
package com.baiyue.lastleg.selfoperated.analytics.sync;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.selfoperated.analytics.client.LingxingAnalyticsClient;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import lombok.Data;
import java.util.Objects;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
/** Account-wide master data uses the same durable task, lease and provider permit as sales. */
@Service
public class SelfopMasterDataCollector {
private final LingxingAnalyticsClient client;
private final SelfopMasterDataMapper data;
private final SelfopSyncTaskMapper tasks;
private final SelfopSyncRunMapper runs;
private final SelfopTaskService taskService;
private final SelfopLeaseService leases;
private final SelfopAnalyticsProperties config;
private final TransactionTemplate tx;
public SelfopMasterDataCollector(LingxingAnalyticsClient client,SelfopMasterDataMapper data,SelfopSyncTaskMapper tasks,
SelfopSyncRunMapper runs,SelfopTaskService taskService,SelfopLeaseService leases,SelfopAnalyticsProperties config,TransactionTemplate tx) {
this.client=client;this.data=data;this.tasks=tasks;this.runs=runs;this.taskService=taskService;this.leases=leases;this.config=config;this.tx=tx;
}
@Data public static class Manifest {
private int nextPage=1,completedPages=0;
private Long total;
private Integer pages;
private long ownerCount,productCount;
private boolean ownersComplete;
}
public void collect(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease) {
SelfopCollectionPolicy.requireBusiness(task);SelfopCollectionPolicy.requireBusiness(run);
require("MASTER_DATA".equals(task.getTaskType())&&lease.account(),"SELFOP_MASTER_TASK_REQUIRED");
var manifest=task.getRoutingManifestJson()==null?new Manifest():read(task.getRoutingManifestJson(),Manifest.class);
// Master-data endpoints need slower paging than outbound. Yield every page so waiting never owns the account lease.
{
leases.renew(lease);boolean owners=!manifest.isOwnersComplete();int size=owners?1000:100;
var context=SelfopRequestContext.forTask(task,run,config);
LingxingAnalyticsClient.Page<?> page=owners?client.customers(context,manifest.nextPage,size):client.products(context,manifest.nextPage,size);
validatePage(manifest,page,size);
try {tx.executeWithoutResult(status->{
var checkpoint=taskService.checkpoint(task,run,lease);
for(var value:page.records()) {
if(owners) {var row=(SelfopPayloads.Owner)value;identity(row.customerCode(),64);data.insertOwner(IdWorker.getId(),run.getId(),task.getSourceAccount(),row,utcNow());manifest.ownerCount++;}
else {var row=(SelfopPayloads.Product)value;identity(row.customerCode(),64);identity(row.sku(),128);data.insertProduct(IdWorker.getId(),run.getId(),task.getSourceAccount(),row,utcNow());manifest.productCount++;}
}
manifest.completedPages++;manifest.nextPage++;
boolean complete=page.pages()==0||manifest.nextPage>page.pages();
if(complete) {
require((owners?manifest.ownerCount:manifest.productCount)==page.total(),"SELFOP_MASTER_COUNT_MISMATCH");
require((owners?data.ownerCount(run.getId(),task.getSourceAccount()):data.productCount(run.getId(),task.getSourceAccount()))==page.total(),"SELFOP_MASTER_COUNT_MISMATCH");
}
var t=checkpoint.task();var r=checkpoint.run();
SelfopProgressProjection.applyCheckpoint(t,r,owners?"CUSTOMERS":"PRODUCTS","PAGE",Math.min(manifest.nextPage-1,page.pages()),page.pages().longValue(),1);
if(complete&&owners){manifest.ownersComplete=true;manifest.nextPage=1;manifest.total=null;manifest.pages=null;}
t.setRoutingManifestJson(json(manifest));t.setUpdatedAt(utcNow());
if(complete&&!owners) {
r.setStatus("PUBLISHED");r.setLastPhase("PUBLISHED");r.setCoverageStatus("COMPLETE");r.setPageCount(manifest.completedPages);
r.setSourceTotalCount(manifest.ownerCount+manifest.productCount);r.setUniqueObjectCount(manifest.ownerCount+manifest.productCount);
r.setFetchFinishedAtUtc(utcNow());r.setPublishedAtUtc(utcNow());r.setFinishedAtUtc(utcNow());r.setUpdatedAt(utcNow());
t.setStatus("SUCCEEDED");t.setResultPublishedRunId(r.getId());
SelfopProgressProjection.applyCheckpoint(t,r,"PUBLISH","BATCH",1,1L,1);
SelfopProgressProjection.freezeRun(t,r);
tasks.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<SelfopSyncTask>().eq("id",t.getId()).set("next_retry_at_utc",null));
t.setNextRetryAtUtc(null);
}
tasks.updateById(t);runs.updateById(r);
});} catch(DuplicateKeyException duplicate){throw new IllegalStateException("SELFOP_MASTER_DUPLICATE");}
if(!owners&&(page.pages()==0||manifest.nextPage>page.pages()))return;
}
taskService.yield(task.getId(),run.getId(),lease);
}
static void validatePage(Manifest m,LingxingAnalyticsClient.Page<?> p,int size) {
require(p.current()!=null&&p.current()==m.nextPage&&p.size()!=null&&p.size()==size,"SELFOP_PAGE_MISMATCH");
require(p.total()!=null&&p.total()>=0&&p.pages()!=null&&p.pages()==(p.total()+size-1)/size,"SELFOP_PAGE_METADATA_MISSING");
require(p.records().size()==Math.min(size,Math.max(0,p.total()-(long)(m.nextPage-1)*size)),"SELFOP_MASTER_COUNT_MISMATCH");
if(m.total==null){m.total=p.total();m.pages=p.pages();}
require(Objects.equals(m.total,p.total())&&Objects.equals(m.pages,p.pages()),"SELFOP_PAGE_TOTAL_CHANGED");
}
}
@@ -95,8 +95,22 @@ public class SelfopSchedulePlanner {
task.setRequestHash(hash(Arrays.asList(task.getQueryScopeJson(),digest)));taskService.create(task); task.setRequestHash(hash(Arrays.asList(task.getQueryScopeJson(),digest)));taskService.create(task);
} }
planContinuousInventory(now,today); planContinuousInventory(now,today);
planMasterData(account,now,today);
}); });
} }
private void planMasterData(SelfopOutboundDiscoveryScope account,LocalDateTime now,LocalDate today) {
// One durable daily slot; unfinished work is resumed rather than replaced by a new day's scan.
if(tasks.selectCount(new QueryWrapper<SelfopSyncTask>().eq("source_account",config.getSourceAccount())
.eq("task_type","MASTER_DATA").in("status","READY","RUNNING","RETRY_WAIT"))>0)return;
var definition=new TreeMap<String,Object>();definition.put("dataFamily","MASTER_DATA");definition.put("sourceAccount",config.getSourceAccount());
String digest=hash(definition),key="continuous:master:"+today+":"+digest.substring(0,12);
if(tasks.selectCount(new QueryWrapper<SelfopSyncTask>().eq("source_account",config.getSourceAccount()).eq("request_key",key))>0)return;
var task=new SelfopSyncTask();task.setSourceAccount(config.getSourceAccount());task.setExecutionPurpose("BUSINESS");
task.setTaskRole("ACCOUNT_DISCOVERY");task.setTaskType("MASTER_DATA");task.setDiscoveryScopeId(account.getId());
task.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);task.setScopeHash(digest);task.setRequestKey(key);
task.setPlannedAtUtc(today.atStartOfDay().minusHours(8));task.setQueryScopeJson(json(definition));
task.setRequestHash(hash(Arrays.asList(task.getQueryScopeJson(),digest)));taskService.create(task);
}
private void planContinuousInventory(LocalDateTime now,LocalDate today){ private void planContinuousInventory(LocalDateTime now,LocalDate today){
var inventory=scopes.selectList(new QueryWrapper<SelfopSyncScope>().eq("source_account",config.getSourceAccount()) var inventory=scopes.selectList(new QueryWrapper<SelfopSyncScope>().eq("source_account",config.getSourceAccount())
.eq("data_family","INVENTORY").in("warehouse_code",config.getWarehouses().keySet())); .eq("data_family","INVENTORY").in("warehouse_code",config.getWarehouses().keySet()));
@@ -151,7 +151,7 @@ public class SelfopTaskService {
require(!"SAMPLE_DONE".equals(task.getStatus()),"SELFOP_VALIDATION_TERMINATED"); require(!"SAMPLE_DONE".equals(task.getStatus()),"SELFOP_VALIDATION_TERMINATED");
if(lease.account()) {var account=locks.lockDiscovery(lease.scopeId());account.setActiveTaskId(taskId);account.setUpdatedAt(utcNow());accounts.updateById(account);} if(lease.account()) {var account=locks.lockDiscovery(lease.scopeId());account.setActiveTaskId(taskId);account.setUpdatedAt(utcNow());accounts.updateById(account);}
var now=utcNow();var active=runs.selectOne(new QueryWrapper<SelfopSyncRun>().eq("task_id",taskId).eq("status","FETCHING").orderByDesc("id").last("LIMIT 1")); var now=utcNow();var active=runs.selectOne(new QueryWrapper<SelfopSyncRun>().eq("task_id",taskId).eq("status","FETCHING").orderByDesc("id").last("LIMIT 1"));
if(task.getScopeId()!=null && "INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())) { if("MASTER_DATA".equals(task.getTaskType()) || task.getScopeId()!=null && "INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())) {
var yielded=runs.selectOne(new QueryWrapper<SelfopSyncRun>().eq("task_id",taskId).eq("status","YIELDED").orderByDesc("id").last("LIMIT 1")); var yielded=runs.selectOne(new QueryWrapper<SelfopSyncRun>().eq("task_id",taskId).eq("status","YIELDED").orderByDesc("id").last("LIMIT 1"));
if(yielded!=null){yielded.setStatus("FETCHING");yielded.setFinishedAtUtc(null);yielded.setProgressSnapshotJson(null);yielded.setFenceToken(lease.fence());yielded.setUpdatedAt(now); if(yielded!=null){yielded.setStatus("FETCHING");yielded.setFinishedAtUtc(null);yielded.setProgressSnapshotJson(null);yielded.setFenceToken(lease.fence());yielded.setUpdatedAt(now);
runs.update(null,new UpdateWrapper<SelfopSyncRun>().eq("id",yielded.getId()).set("status","FETCHING").set("fence_token",lease.fence()).set("finished_at_utc",null).set("progress_snapshot_json",null).set("updated_at",now)); runs.update(null,new UpdateWrapper<SelfopSyncRun>().eq("id",yielded.getId()).set("status","FETCHING").set("fence_token",lease.fence()).set("finished_at_utc",null).set("progress_snapshot_json",null).set("updated_at",now));
@@ -160,7 +160,7 @@ public class SelfopTaskService {
if(active!=null){require(!Objects.equals(active.getFenceToken(),lease.fence()),"SELFOP_TASK_ALREADY_RUNNING"); if(active!=null){require(!Objects.equals(active.getFenceToken(),lease.fence()),"SELFOP_TASK_ALREADY_RUNNING");
SelfopProgressProjection.freezeRun(task,active); SelfopProgressProjection.freezeRun(task,active);
active.setStatus("ABORTED");active.setLastPhase("FETCHING");active.setFinishedAtUtc(now);active.setErrorCode("LEASE_EXPIRED");active.setErrorMessage("执行租约失效");active.setUpdatedAt(now);runs.updateById(active);} active.setStatus("ABORTED");active.setLastPhase("FETCHING");active.setFinishedAtUtc(now);active.setErrorCode("LEASE_EXPIRED");active.setErrorMessage("执行租约失效");active.setUpdatedAt(now);runs.updateById(active);}
if(task.getScopeId()!=null&&"INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())) { if("MASTER_DATA".equals(task.getTaskType()) || task.getScopeId()!=null&&"INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())) {
// Every genuinely new inventory attempt starts at page one, including a crash that never called fail(). // Every genuinely new inventory attempt starts at page one, including a crash that never called fail().
task.setRoutingManifestJson(null); task.setRoutingManifestJson(null);
tasks.update(null,new UpdateWrapper<SelfopSyncTask>().eq("id",taskId).set("routing_manifest_json",null)); tasks.update(null,new UpdateWrapper<SelfopSyncTask>().eq("id",taskId).set("routing_manifest_json",null));
@@ -180,7 +180,7 @@ public class SelfopTaskService {
if(sales!=null)sales=locks.lockScope(sales.getId()); if(sales!=null)sales=locks.lockScope(sales.getId());
run.setReferenceSalesRunId(sales==null?null:sales.getPublishedRunId());}} run.setReferenceSalesRunId(sales==null?null:sales.getPublishedRunId());}}
runs.insert(run);task.setAttemptCount(run.getAttemptNo());task.setStatus("RUNNING");task.setUpdatedAt(now); runs.insert(run);task.setAttemptCount(run.getAttemptNo());task.setStatus("RUNNING");task.setUpdatedAt(now);
String initialPhase=lease.account()?"DIRECTORY":("INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())?"INVENTORY":"MERGE"); String initialPhase="MASTER_DATA".equals(task.getTaskType())?"CUSTOMERS":lease.account()?"DIRECTORY":("INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())?"INVENTORY":"MERGE");
SelfopProgressProjection.applyCheckpoint(task,run,initialPhase,"PAGE",0,null,1);tasks.updateById(task);return run; SelfopProgressProjection.applyCheckpoint(task,run,initialPhase,"PAGE",0,null,1);tasks.updateById(task);return run;
} }
@Transactional(noRollbackFor=BusinessSessionStoppedException.class) @Transactional(noRollbackFor=BusinessSessionStoppedException.class)
@@ -188,7 +188,8 @@ public class SelfopTaskService {
lockBusinessAccount(taskId);leases.verify(lease); lockBusinessAccount(taskId);leases.verify(lease);
guardBeforeTaskLock(taskId); guardBeforeTaskLock(taskId);
var now=utcNow();var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run);SelfopProgressProjection.freezeRun(task,run);run.setStatus("YIELDED");run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run); var now=utcNow();var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run);SelfopProgressProjection.freezeRun(task,run);run.setStatus("YIELDED");run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run);
task.setStatus("RETRY_WAIT");task.setNextRetryAtUtc(now.plusSeconds(1));task.setUpdatedAt(now);tasks.updateById(task); // Persist the master-data pacing across restarts; ordinary sales/inventory scheduling stays unchanged.
task.setStatus("RETRY_WAIT");task.setNextRetryAtUtc(now.plusSeconds("MASTER_DATA".equals(task.getTaskType())?5:1));task.setUpdatedAt(now);tasks.updateById(task);
} }
@Transactional(noRollbackFor=BusinessSessionStoppedException.class) @Transactional(noRollbackFor=BusinessSessionStoppedException.class)
public void fail(long taskId,long runId,String code,boolean retryable,SelfopLeaseService.Lease lease) { public void fail(long taskId,long runId,String code,boolean retryable,SelfopLeaseService.Lease lease) {
@@ -202,7 +203,8 @@ public class SelfopTaskService {
if(task.getBusinessRootTaskId()!=null&&continuousBusinessRoot(tasks.selectById(task.getBusinessRootTaskId()))) { if(task.getBusinessRootTaskId()!=null&&continuousBusinessRoot(tasks.selectById(task.getBusinessRootTaskId()))) {
// A completed response with an unstable inventory scan can be read again from page one. // A completed response with an unstable inventory scan can be read again from page one.
// Authentication, rate limits, uncertain transport results and other families retain their stop policy. // Authentication, rate limits, uncertain transport results and other families retain their stop policy.
retryable=retryable&&inventory&&Set.of("SELFOP_STOCK_DUPLICATE","SELFOP_STOCK_COUNT_MISMATCH","SELFOP_PAGE_TOTAL_CHANGED").contains(code); retryable=retryable&&(inventory&&Set.of("SELFOP_STOCK_DUPLICATE","SELFOP_STOCK_COUNT_MISMATCH","SELFOP_PAGE_TOTAL_CHANGED").contains(code)
||"MASTER_DATA".equals(task.getTaskType())&&Set.of("SELFOP_MASTER_DUPLICATE","SELFOP_MASTER_COUNT_MISMATCH","SELFOP_PAGE_TOTAL_CHANGED").contains(code));
} }
task.setStatus(retryable&&failures<config.getMaxAttempts()?"RETRY_WAIT":"DEAD"); task.setStatus(retryable&&failures<config.getMaxAttempts()?"RETRY_WAIT":"DEAD");
if("APPLYING".equals(task.getApplyState())) task.setApplyState("BLOCKED"); if("APPLYING".equals(task.getApplyState())) task.setApplyState("BLOCKED");
@@ -3965,3 +3965,37 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo (
UNIQUE KEY uk_cargo_discard_photo_file (file_id), UNIQUE KEY uk_cargo_discard_photo_file (file_id),
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no) KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE ll_selfop_owner (
id BIGINT NOT NULL COMMENT '主键',
run_id BIGINT NOT NULL COMMENT '完整资料采集版本',
source_account VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源账号',
customer_code VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主编码',
customer_name VARCHAR(255) NULL COMMENT '领星货主名称',
source_status VARCHAR(32) NULL COMMENT '来源启停状态',
observed_at_utc DATETIME(6) NOT NULL COMMENT '资料观测时间',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_owner_version (run_id,source_account,customer_code),
KEY idx_selfop_owner_lookup (source_account,customer_code,run_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星货主资料版本';
CREATE TABLE ll_selfop_product (
id BIGINT NOT NULL COMMENT '主键',
run_id BIGINT NOT NULL COMMENT '完整资料采集版本',
source_account VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源账号',
customer_code VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主编码',
sku_code VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '货主商品编码',
product_name VARCHAR(255) NULL COMMENT '商品名称',
declared_length DECIMAL(24,6) NULL COMMENT '商品资料长度',
declared_width DECIMAL(24,6) NULL COMMENT '商品资料宽度',
declared_height DECIMAL(24,6) NULL COMMENT '商品资料高度',
declared_size_unit VARCHAR(32) NULL COMMENT '商品资料尺寸单位',
measured_length DECIMAL(24,6) NULL COMMENT '仓库实测长度',
measured_width DECIMAL(24,6) NULL COMMENT '仓库实测宽度',
measured_height DECIMAL(24,6) NULL COMMENT '仓库实测高度',
measured_size_unit VARCHAR(32) NULL COMMENT '仓库实测尺寸单位',
observed_at_utc DATETIME(6) NOT NULL COMMENT '资料观测时间',
PRIMARY KEY (id),
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='自营领星商品资料版本';
@@ -0,0 +1,33 @@
CREATE TABLE ll_selfop_owner (
id BIGINT NOT NULL COMMENT '主键',
run_id BIGINT NOT NULL COMMENT '完整资料采集版本',
source_account VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源账号',
customer_code VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主编码',
customer_name VARCHAR(255) NULL COMMENT '领星货主名称',
source_status VARCHAR(32) NULL COMMENT '来源启停状态',
observed_at_utc DATETIME(6) NOT NULL COMMENT '资料观测时间',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_owner_version (run_id,source_account,customer_code),
KEY idx_selfop_owner_lookup (source_account,customer_code,run_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星货主资料版本';
CREATE TABLE ll_selfop_product (
id BIGINT NOT NULL COMMENT '主键',
run_id BIGINT NOT NULL COMMENT '完整资料采集版本',
source_account VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源账号',
customer_code VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主编码',
sku_code VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '货主商品编码',
product_name VARCHAR(255) NULL COMMENT '商品名称',
declared_length DECIMAL(24,6) NULL COMMENT '商品资料长度',
declared_width DECIMAL(24,6) NULL COMMENT '商品资料宽度',
declared_height DECIMAL(24,6) NULL COMMENT '商品资料高度',
declared_size_unit VARCHAR(32) NULL COMMENT '商品资料尺寸单位',
measured_length DECIMAL(24,6) NULL COMMENT '仓库实测长度',
measured_width DECIMAL(24,6) NULL COMMENT '仓库实测宽度',
measured_height DECIMAL(24,6) NULL COMMENT '仓库实测高度',
measured_size_unit VARCHAR(32) NULL COMMENT '仓库实测尺寸单位',
observed_at_utc DATETIME(6) NOT NULL COMMENT '资料观测时间',
PRIMARY KEY (id),
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='自营领星商品资料版本';
@@ -132,7 +132,7 @@ components:
exclusiveGrantId: { type: string, maxLength: 128 } exclusiveGrantId: { type: string, maxLength: 128 }
Operation: Operation:
type: string type: string
enum: [WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY] enum: [WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY, CUSTOMERS, PRODUCTS]
AuthorizationRequest: AuthorizationRequest:
type: object type: object
required: [context, operation, requestHash] required: [context, operation, requestHash]
@@ -21,6 +21,31 @@ info:
重试沿既有POST sync-tasks,action=RETRY/requestKey/warehouseCode/taskNo/reason,使用独立sync权限并最终复核资格。 重试沿既有POST sync-tasks,action=RETRY/requestKey/warehouseCode/taskNo/reason,使用独立sync权限并最终复核资格。
security: [{bearerAuth: []}] security: [{bearerAuth: []}]
paths: paths:
/api/platform/v1/last-leg/self-operated-analytics/owner-directory:
get:
summary: 完整资料批次中的客户名称与编码
description: 仅平台且须 platform:self-operated-analytics:list。按账号和 queryAsOf 选择最近完整发布的 MASTER_DATA 资料版本;不调用领星,不限制为当前仓有销量的客户。未同步返回空数组,缺失名称返回 null。
parameters:
- {$ref: '#/components/parameters/sourceAccount'}
- {$ref: '#/components/parameters/warehouseCode'}
- {$ref: '#/components/parameters/salesRunId'}
- {$ref: '#/components/parameters/inventoryRunId'}
- {$ref: '#/components/parameters/salesScopeHash'}
- {$ref: '#/components/parameters/inventoryScopeHash'}
- {$ref: '#/components/parameters/queryAsOf'}
responses:
'200':
description: 统一 ApiResponse
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data: {type: array, items: {$ref: '#/components/schemas/OwnerOption'}}
'401': {description: 缺少可信认证身份}
'403': {description: 非平台身份或缺少只读权限}
/api/platform/v1/last-leg/self-operated-analytics/dashboard: /api/platform/v1/last-leg/self-operated-analytics/dashboard:
get: get:
summary: 单仓固定版本经营总览 summary: 单仓固定版本经营总览
@@ -482,6 +507,7 @@ components:
DashboardRankingItem: DashboardRankingItem:
type: object type: object
properties: properties:
customerName: {type: string, nullable: true, description: queryAsOf 时可见的完整资料批次中的货主名称}
customerCode: {type: string} customerCode: {type: string}
sku: {type: string} sku: {type: string}
productName: {type: string, nullable: true} productName: {type: string, nullable: true}
@@ -603,6 +629,7 @@ components:
SalesSummary: SalesSummary:
type: object type: object
properties: properties:
customerName: {type: string, nullable: true, description: queryAsOf 时可见的完整资料批次中的货主名称}
sourceAccount: {type: string, nullable: true} sourceAccount: {type: string, nullable: true}
warehouseCode: {type: string, nullable: true} warehouseCode: {type: string, nullable: true}
customerCode: {type: string, nullable: true} customerCode: {type: string, nullable: true}
@@ -721,7 +748,7 @@ components:
taskNo: {type: string, nullable: true} taskNo: {type: string, nullable: true}
attemptNo: {type: integer, nullable: true} attemptNo: {type: integer, nullable: true}
status: {type: string, nullable: true} status: {type: string, nullable: true}
phase: {type: string, nullable: true} phase: {type: string, nullable: true, description: 资料同步使用 CUSTOMERS / PRODUCTS;其他阶段不变}
executionPurpose: {type: string, nullable: true} executionPurpose: {type: string, nullable: true}
startedAt: {type: string, format: date-time, nullable: true} startedAt: {type: string, format: date-time, nullable: true}
finishedAt: {type: string, format: date-time, nullable: true} finishedAt: {type: string, format: date-time, nullable: true}
@@ -745,7 +772,7 @@ components:
elapsedSeconds: {type: string, nullable: true, description: 自创建起的累计秒数,含排队和重试;活动任务截止本次读取,终态任务截止任务最后更新时间;不是单次执行耗时} elapsedSeconds: {type: string, nullable: true, description: 自创建起的累计秒数,含排队和重试;活动任务截止本次读取,终态任务截止任务最后更新时间;不是单次执行耗时}
taskNo: {type: string, nullable: true} taskNo: {type: string, nullable: true}
taskType: {type: string, nullable: true} taskType: {type: string, nullable: true}
dataFamily: {type: string, nullable: true} dataFamily: {type: string, nullable: true, description: OUTBOUND / INVENTORY / MASTER_DATA(账号资料)}
warehouseCode: {type: string, nullable: true} warehouseCode: {type: string, nullable: true}
taskRole: {type: string, nullable: true} taskRole: {type: string, nullable: true}
executionPurpose: {type: string, nullable: true} executionPurpose: {type: string, nullable: true}
@@ -814,7 +841,7 @@ components:
properties: properties:
warehouseCode: {type: string, nullable: true} warehouseCode: {type: string, nullable: true}
warehouseName: {type: string, nullable: true} warehouseName: {type: string, nullable: true}
dataFamily: {type: string, nullable: true} dataFamily: {type: string, nullable: true, description: OUTBOUND / INVENTORY / MASTER_DATA(账号资料)}
executionEnabled: {type: boolean, nullable: true} executionEnabled: {type: boolean, nullable: true}
publicationEnabled: {type: boolean, nullable: true} publicationEnabled: {type: boolean, nullable: true}
disabledReason: {type: string, nullable: true} disabledReason: {type: string, nullable: true}
@@ -861,7 +888,7 @@ components:
countBasis: {type: string, nullable: true} countBasis: {type: string, nullable: true}
attemptNo: {type: string, nullable: true} attemptNo: {type: string, nullable: true}
scanRound: {type: string, nullable: true} scanRound: {type: string, nullable: true}
phase: {type: string, nullable: true} phase: {type: string, nullable: true, description: 资料同步使用 CUSTOMERS / PRODUCTS;其他阶段不变}
completed: {type: string, nullable: true} completed: {type: string, nullable: true}
total: {type: string, nullable: true} total: {type: string, nullable: true}
totalKnown: {type: boolean, nullable: true} totalKnown: {type: boolean, nullable: true}
@@ -34,6 +34,20 @@ class RestLingxingAnalyticsClientTest {
properties.setSourceAccount("synthetic-account"); properties.setSourceAccount("synthetic-account");
properties.setInternalServiceToken("synthetic-token"); properties.setInternalServiceToken("synthetic-token");
} }
@Test void masterPagesKeepNamesAndSeparateDeclaredAndMeasuredDimensions() {
enable();
expect("/customers/query", "{\"current\":1,\"size\":1000}", """
{"code":"200","data":{"current":1,"size":1000,"pages":1,"total":"1","records":[{"customerCode":"C","customerName":"客户","status":"0"}]}}
""");
expect("/products/query", "{\"current\":1,\"size\":100}", """
{"code":"200","data":{"current":1,"size":100,"pages":1,"total":"1","records":[{"customerCode":"C","sku":"SKU","productName":"商品","length":5,"width":11,"height":5,"sizeUnit":"cm","wmsLength":null,"wmsWidth":null,"wmsHeight":null,"wmsSizeUnit":null}]}}
""");
assertThat(client.customers(context("synthetic-account"),1,1000).records().get(0).customerName()).isEqualTo("客户");
var product=client.products(context("synthetic-account"),1,100).records().get(0);
assertThat(product.length()).isEqualByComparingTo("5");assertThat(product.sizeUnit()).isEqualTo("cm");assertThat(product.wmsLength()).isNull();assertThat(product.wmsSizeUnit()).isNull();
verify(egress).reserve(any(),eq(SelfopEgressDtos.Operation.CUSTOMERS),eq(1000),isNull(),isNull(),any());
verify(egress).reserve(any(),eq(SelfopEgressDtos.Operation.PRODUCTS),eq(100),isNull(),isNull(),any());server.verify();
}
@Test void disabledByDefaultAndAccountBindingFailBeforeTransport() { @Test void disabledByDefaultAndAccountBindingFailBeforeTransport() {
assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100)).hasMessage("EXECUTION_DISABLED"); assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100)).hasMessage("EXECUTION_DISABLED");
enable(); enable();
@@ -26,6 +26,7 @@ class PlatformAnalyticsControllerTest {
var controller=new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class)); var controller=new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class));
var user=new AuthenticatedUser(1L,2L,2L,1L,"demo","CLIENT","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION)); var user=new AuthenticatedUser(1L,2L,2L,1L,"demo","CLIENT","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION));
assertThrows(ResponseStatusException.class,()->controller.inventoryCoverage(user,null,null)); assertThrows(ResponseStatusException.class,()->controller.inventoryCoverage(user,null,null));
assertThrows(ResponseStatusException.class,()->controller.ownerDirectory(user,null));
assertThrows(ResponseStatusException.class,()->controller.syncTaskDetail(user,"LXCOL-DEMO",null)); assertThrows(ResponseStatusException.class,()->controller.syncTaskDetail(user,"LXCOL-DEMO",null));
verifyNoInteractions(service,collection); verifyNoInteractions(service,collection);
} }
@@ -34,6 +35,7 @@ class PlatformAnalyticsControllerTest {
var controller=new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class)); var controller=new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class));
var user=new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of("platform:self-operated-analytics:sync")); var user=new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of("platform:self-operated-analytics:sync"));
assertThrows(ResponseStatusException.class,()->controller.hotSkus(user,null)); assertThrows(ResponseStatusException.class,()->controller.hotSkus(user,null));
assertThrows(ResponseStatusException.class,()->controller.ownerDirectory(user,null));
assertThrows(ResponseStatusException.class,()->controller.syncOverview(user,null)); assertThrows(ResponseStatusException.class,()->controller.syncOverview(user,null));
assertThrows(ResponseStatusException.class,()->controller.slowSkus(null,null)); assertThrows(ResponseStatusException.class,()->controller.slowSkus(null,null));
verifyNoInteractions(service,collection); verifyNoInteractions(service,collection);
@@ -42,7 +44,8 @@ class PlatformAnalyticsControllerTest {
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
var c=new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class));var user=platform(); var c=new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class));var user=platform();
c.salesSummary(user,null,com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC);c.dailySales(user,null);c.hotSkus(user,null);c.slowSkus(user,null);c.inventoryCoverage(user,null,null); c.salesSummary(user,null,com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC);c.dailySales(user,null);c.hotSkus(user,null);c.slowSkus(user,null);c.inventoryCoverage(user,null,null);
c.owners(user,null,null);c.ownerOverview(user,null,null);c.syncOverview(user,null);c.syncTasks(user,null);c.syncTaskDetail(user,"LXCOL-DEMO",null);c.syncRuns(user,null); c.ownerDirectory(user,null);c.owners(user,null,null);c.ownerOverview(user,null,null);c.syncOverview(user,null);c.syncTasks(user,null);c.syncTaskDetail(user,"LXCOL-DEMO",null);c.syncRuns(user,null);
verify(service).ownerDirectory(null);
verify(service).salesSummary(null,com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC);verify(service).dailySales(null);verify(service).hotSkus(null); verify(service).salesSummary(null,com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC);verify(service).dailySales(null);verify(service).hotSkus(null);
verify(service).slowSkus(null);verify(service).inventoryCoverage(null,null); verify(service).slowSkus(null);verify(service).inventoryCoverage(null,null);
verify(service).owners(null,null);verify(service).ownerOverview(null,null);verify(collection).overview(null);verify(collection).tasks(null);verify(collection).taskDetail("LXCOL-DEMO",null);verify(collection).runs(null); verify(service).owners(null,null);verify(service).ownerOverview(null,null);verify(collection).overview(null);verify(collection).tasks(null);verify(collection).taskDetail("LXCOL-DEMO",null);verify(collection).runs(null);
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopPayloads;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.*;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.*;
import java.sql.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/** Real SQL, source DDL and transaction rollback against an offline synthetic database. */
class SelfopMasterDataMapperTest {
JdbcDataSource data;SqlSessionFactory factory;SqlSession session;SelfopMasterDataMapper mapper;
final LocalDateTime now=LocalDateTime.of(2026,10,14,8,0);
@BeforeEach void setup() throws Exception {
data=new JdbcDataSource();data.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1");
try(var stream=getClass().getResourceAsStream("/db/selfop-master-data.sql")) {
String ddl=new String(stream.readAllBytes(),StandardCharsets.UTF_8).replace(" COLLATE utf8mb4_bin","").replaceAll(" ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='[^']*'","");
for(String statement:ddl.split(";"))if(!statement.isBlank())sql(statement);
}
sql("CREATE TABLE ll_selfop_sync_task(id BIGINT PRIMARY KEY,source_account VARCHAR(64),task_type VARCHAR(32),task_role VARCHAR(32),execution_purpose VARCHAR(32))");
sql("CREATE TABLE ll_selfop_sync_run(id BIGINT PRIMARY KEY,task_id BIGINT,source_account VARCHAR(64),execution_purpose VARCHAR(32),status VARCHAR(32),coverage_status VARCHAR(32),published_at_utc TIMESTAMP)");
var config=new Configuration(new Environment("offline",new JdbcTransactionFactory(),data));
config.setMapUnderscoreToCamelCase(true);config.setLocalCacheScope(LocalCacheScope.STATEMENT);config.addMapper(SelfopMasterDataMapper.class);
factory=new SqlSessionFactoryBuilder().build(config);session=factory.openSession(true);mapper=session.getMapper(SelfopMasterDataMapper.class);
}
@AfterEach void close(){session.close();}
void sql(String query)throws Exception{try(var c=data.getConnection();var s=c.createStatement()){s.execute(query);}}
SelfopPayloads.Product product(String owner){return new SelfopPayloads.Product(owner,"SKU","商品",new BigDecimal("5"),new BigDecimal("11"),new BigDecimal("5"),"cm",null,null,null,null);}
@Test void namesAndProductsAreIsolatedByAccountOwnerAndBatch()throws Exception{
mapper.insertOwner(1,10,"A",new SelfopPayloads.Owner("C","客户","0"),now);
mapper.insertOwner(2,10,"B",new SelfopPayloads.Owner("C","另一账号","1"),now);
mapper.insertProduct(3,10,"A",product("C"),now);mapper.insertProduct(4,10,"A",product("D"),now);
assertEquals("客户",mapper.owners("A",10).get(0).customerName());assertEquals("0",mapper.owners("A",10).get(0).status());
assertEquals(1,mapper.ownerCount(10,"A"));assertEquals(2,mapper.productCount(10,"A"));assertEquals(0,mapper.productCount(11,"A"));
assertThrows(RuntimeException.class,()->mapper.insertProduct(5,10,"A",product("C"),now));
try(var c=data.getConnection();var s=c.createStatement();var rs=s.executeQuery("SELECT declared_length,declared_size_unit,measured_length,measured_size_unit FROM ll_selfop_product WHERE id=3")){
assertTrue(rs.next());assertEquals(0,new BigDecimal("5").compareTo(rs.getBigDecimal(1)));assertEquals("cm",rs.getString(2));assertNull(rs.getBigDecimal(3));assertNull(rs.getString(4));
}
}
@Test void onlyCompleteBusinessMasterBatchesPublishedBeforeAsOfAreVisible()throws Exception{
sql("INSERT INTO ll_selfop_sync_task VALUES(1,'A','MASTER_DATA','ACCOUNT_DISCOVERY','BUSINESS'),(2,'A','BACKFILL','ACCOUNT_DISCOVERY','BUSINESS'),(3,'B','MASTER_DATA','ACCOUNT_DISCOVERY','BUSINESS')");
sql("INSERT INTO ll_selfop_sync_run VALUES(10,1,'A','BUSINESS','PUBLISHED','COMPLETE','2026-10-14 07:00:00'),(11,1,'A','BUSINESS','PUBLISHED','COMPLETE','2026-10-14 09:00:00'),(12,1,'A','BUSINESS','FETCHING','COMPLETE','2026-10-14 07:59:00'),(13,1,'A','BUSINESS','PUBLISHED','PARTIAL','2026-10-14 07:59:00'),(14,2,'A','BUSINESS','PUBLISHED','COMPLETE','2026-10-14 07:59:00'),(15,3,'B','BUSINESS','PUBLISHED','COMPLETE','2026-10-14 07:59:00')");
assertEquals(10L,mapper.publishedAt("A",now));assertEquals(11L,mapper.publishedAt("A",now.plusHours(2)));assertNull(mapper.publishedAt("A",now.minusHours(2)));assertEquals(15L,mapper.publishedAt("B",now));
}
@Test void failedPageTransactionRollsBackEveryRow(){
try(var transaction=factory.openSession(false)){
var writer=transaction.getMapper(SelfopMasterDataMapper.class);
writer.insertOwner(1,10,"A",new SelfopPayloads.Owner("C","客户","0"),now);
assertThrows(RuntimeException.class,()->writer.insertOwner(2,10,"A",new SelfopPayloads.Owner("C","重复","0"),now));
transaction.rollback();
}
assertEquals(0,mapper.ownerCount(10,"A"));
}
}
@@ -16,6 +16,15 @@ import static org.mockito.Mockito.*;
/** Offline contracts: SQL supplies bounded pages, never an in-memory warehouse history. */ /** Offline contracts: SQL supplies bounded pages, never an in-memory warehouse history. */
class AnalyticsQueryServiceTest { class AnalyticsQueryServiceTest {
@Test void namesUseFrozenLocalDirectoryWithoutChangingSkuIdentityOrQuantities(){
var directory=mock(SelfopOwnerDirectory.class);when(directory.names("demo",NOW)).thenReturn(Map.of("C","货主名称"));
var named=new AnalyticsQueryService(mapper,new ObjectMapper(),Clock.fixed(NOW,ZoneOffset.UTC),directory);
var before=service.inventoryCoverage(metrics(null),options()).records().get(0);
var after=named.inventoryCoverage(metrics(null),options()).records().get(0);
assertEquals("货主名称",after.customerName());assertEquals(before.customerCode(),after.customerCode());assertEquals(before.sku(),after.sku());
assertEquals(before.physicalQuantity(),after.physicalQuantity());assertEquals(before.windowOutboundQuantity(),after.windowOutboundQuantity());
assertEquals("货主名称",named.ownerDirectory(metrics(null)).get(0).customerName());
}
@Test void frozenHistoryStartExistsBeforeContinuousAnchorAndIncludesEarlierDisconnectedDays(){ @Test void frozenHistoryStartExistsBeforeContinuousAnchorAndIncludesEarlierDisconnectedDays(){
when(mapper.publishedRun(11)).thenReturn(frozenRun(null,null, when(mapper.publishedRun(11)).thenReturn(frozenRun(null,null,
new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval(time(D.minusDays(1)),time(D)))); new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval(time(D.minusDays(1)),time(D))));
@@ -398,7 +407,7 @@ class AnalyticsQueryServiceTest {
return new Inventory("C","SKU","Product",unit,evidence,fresh?n("120"):null,stock,fresh?n("20"):null,fresh?n("30"):null, return new Inventory("C","SKU","Product",unit,evidence,fresh?n("120"):null,stock,fresh?n("20"):null,fresh?n("30"):null,
fresh?n("0"):null,utc(NOW.minusSeconds(3600)),out,out,10L,D.minusDays(1),evidence!=null,out,null,0,0,out==null||stock==null); fresh?n("0"):null,utc(NOW.minusSeconds(3600)),out,out,10L,D.minusDays(1),evidence!=null,out,null,0,0,out==null||stock==null);
} }
private AnalyticsQueryService at(Instant now){return new AnalyticsQueryService(mapper,new ObjectMapper(),Clock.fixed(now,ZoneOffset.UTC));} private AnalyticsQueryService at(Instant now){return new AnalyticsQueryService(mapper,new ObjectMapper(),Clock.fixed(now,ZoneOffset.UTC),org.mockito.Mockito.mock(SelfopOwnerDirectory.class));}
private static AnalyticsQuery chart(String range,LocalDate from,LocalDate until,String owner,String sku){return new AnalyticsQuery("demo","W",owner,sku,range,from,until,"DAY",null,1,20,null,null,null,null,null);} private static AnalyticsQuery chart(String range,LocalDate from,LocalDate until,String owner,String sku){return new AnalyticsQuery("demo","W",owner,sku,range,from,until,"DAY",null,1,20,null,null,null,null,null);}
private static AnalyticsQuery metrics(Integer days){return new AnalyticsQuery("demo","W",null,null,null,null,null,null,days,1,20,null,null,null,null,null);} private static AnalyticsQuery metrics(Integer days){return new AnalyticsQuery("demo","W",null,null,null,null,null,null,days,1,20,null,null,null,null,null);}
private static AnalyticsQuery pinned(String range,long sales,long inventory,String sh,String ih,Instant asOf,int page){return new AnalyticsQuery("demo","W",null,null,range,null,null,range==null?null:"DAY",null,page,20,sales,inventory,sh,ih,asOf);} private static AnalyticsQuery pinned(String range,long sales,long inventory,String sh,String ih,Instant asOf,int page){return new AnalyticsQuery("demo","W",null,null,range,null,null,range==null?null:"DAY",null,page,20,sales,inventory,sh,ih,asOf);}
@@ -13,7 +13,7 @@ import static org.mockito.Mockito.*;
class DashboardQueryServiceTest { class DashboardQueryServiceTest {
@Test void selectedWindowKeepsHotAvailableButDoesNotInventAverageWhenIncomplete()throws Exception{ @Test void selectedWindowKeepsHotAvailableButDoesNotInventAverageWhenIncomplete()throws Exception{
var contexts=mock(AnalyticsQueryService.class);var mapper=mock(DashboardReadMapper.class);var service=new DashboardQueryService(contexts,mapper); var contexts=mock(AnalyticsQueryService.class);var mapper=mock(DashboardReadMapper.class);var service=new DashboardQueryService(contexts,mapper,org.mockito.Mockito.mock(SelfopOwnerDirectory.class));
LocalDate day=DashboardPolicyTest.DAY;Instant now=DashboardPolicyTest.NOW; LocalDate day=DashboardPolicyTest.DAY;Instant now=DashboardPolicyTest.NOW;
var q=new AnalyticsQuery("demo","W",null,null,"TODAY",null,null,null,null,null,null,null,null,null,null,null); var q=new AnalyticsQuery("demo","W",null,null,"TODAY",null,null,null,null,null,null,null,null,null,null,null);
var context=DashboardPolicyTest.context(day.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),now,false); var context=DashboardPolicyTest.context(day.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),now,false);
@@ -38,7 +38,7 @@ class DashboardQueryServiceTest {
assertTrue(DashboardQueryService.class.getAnnotation(Transactional.class).readOnly()); assertTrue(DashboardQueryService.class.getAnnotation(Transactional.class).readOnly());
} }
@Test void unreadyPublishedVersionIsRejectedWhileReadyEmptyWarehouseProvesZeroAndAbsentSalesStayUnknown(){ @Test void unreadyPublishedVersionIsRejectedWhileReadyEmptyWarehouseProvesZeroAndAbsentSalesStayUnknown(){
var contexts=mock(AnalyticsQueryService.class);var mapper=mock(DashboardReadMapper.class);var service=new DashboardQueryService(contexts,mapper); var contexts=mock(AnalyticsQueryService.class);var mapper=mock(DashboardReadMapper.class);var service=new DashboardQueryService(contexts,mapper,org.mockito.Mockito.mock(SelfopOwnerDirectory.class));
LocalDate day=DashboardPolicyTest.DAY;Instant now=DashboardPolicyTest.NOW; LocalDate day=DashboardPolicyTest.DAY;Instant now=DashboardPolicyTest.NOW;
var q=new AnalyticsQuery("demo","W",null,null,"TODAY",null,null,null,null,null,null,null,null,null,null,null); var q=new AnalyticsQuery("demo","W",null,null,"TODAY",null,null,null,null,null,null,null,null,null,null,null);
var context=DashboardPolicyTest.context(day.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),now,false,day.minusDays(15),day); var context=DashboardPolicyTest.context(day.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),now,false,day.minusDays(15),day);
@@ -107,7 +107,7 @@ class InventoryTurnoverContextTest {
when(analytics.applyBarriers("demo","W")).thenReturn(1L); when(analytics.applyBarriers("demo","W")).thenReturn(1L);
when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}"), when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}"),
Map.of("section","OVERVIEW","payload","{\"sku_count\":0}"))); Map.of("section","OVERVIEW","payload","{\"sku_count\":0}")));
var result=new InventoryTurnoverQueryService(contexts,mapper,json).query(request(null)); var result=new InventoryTurnoverQueryService(contexts,mapper,json,org.mockito.Mockito.mock(SelfopOwnerDirectory.class)).query(request(null));
assertTrue(result.context().analyticsContext().rebuildRequired()); assertTrue(result.context().analyticsContext().rebuildRequired());
verify(mapper).report(argThat(p->p.days().stream().allMatch(d->d.coverage()==0))); verify(mapper).report(argThat(p->p.days().stream().allMatch(d->d.coverage()==0)));
verify(mapper).chartWindows(argThat(p->p.salesRun()==11L&&p.inventoryRun()==22L&&p.salesKnown() verify(mapper).chartWindows(argThat(p->p.salesRun()==11L&&p.inventoryRun()==22L&&p.salesKnown()
@@ -116,7 +116,7 @@ class InventoryTurnoverContextTest {
&&p.days().subList(15,30).stream().allMatch(d->d.coverage()==1))); &&p.days().subList(15,30).stream().allMatch(d->d.coverage()==1)));
} }
@Test void firstPageSharesAggregationAndPaginationDoesNotRepeatOverviewQueries() { @Test void firstPageSharesAggregationAndPaginationDoesNotRepeatOverviewQueries() {
var service=new InventoryTurnoverQueryService(contexts,mapper,json); var service=new InventoryTurnoverQueryService(contexts,mapper,json,org.mockito.Mockito.mock(SelfopOwnerDirectory.class));
when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}"), when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}"),
Map.of("section","OVERVIEW","payload","{\"sku_count\":0,\"physical\":0,\"available\":0,\"no_outbound_count\":0,\"unassessable\":0}"))); Map.of("section","OVERVIEW","payload","{\"sku_count\":0,\"physical\":0,\"available\":0,\"no_outbound_count\":0,\"unassessable\":0}")));
var first=service.query(request(null));assertEquals(0,first.total());assertNotNull(first.summary());assertNotNull(first.charts()); var first=service.query(request(null));assertEquals(0,first.total());assertNotNull(first.summary());assertNotNull(first.charts());
@@ -127,7 +127,7 @@ class InventoryTurnoverContextTest {
var q=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,2,20,false,first.context().snapshotContext()); var q=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,2,20,false,first.context().snapshotContext());
var next=service.query(q);assertNull(next.summary());assertNull(next.charts());verify(mapper).report(any());verify(mapper,never()).chartWindows(any()); var next=service.query(q);assertNull(next.summary());assertNull(next.charts());verify(mapper).report(any());verify(mapper,never()).chartWindows(any());
} }
private InventoryTurnoverContext at(Instant instant){return new InventoryTurnoverContext(analytics,mapper,json,Clock.fixed(instant,ZoneOffset.UTC));} 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);} static InventoryTurnoverRequest request(String token){return new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token);}
private static LocalDateTime utc(Instant instant){return LocalDateTime.ofInstant(instant,ZoneOffset.UTC);} private static LocalDateTime utc(Instant instant){return LocalDateTime.ofInstant(instant,ZoneOffset.UTC);}
private static Scope scope(long id,String family,String hash,long run){return new Scope(id,"demo","W",family,hash,"Asia/Shanghai","Asia/Shanghai",93600,run,1L,1L,false,null,null,7);} private static Scope scope(long id,String family,String hash,long run){return new Scope(id,"demo","W",family,hash,"Asia/Shanghai","Asia/Shanghai",93600,run,1L,1L,false,null,null,7);}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.*;
import java.time.Instant;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
class InventoryTurnoverQueryServiceTest {
@Test void totalsWithoutCustomerIdentityRemainReadableBeforeFirstDirectoryPublication(){
var contexts=mock(InventoryTurnoverContext.class);var mapper=mock(InventoryTurnoverReadMapper.class);var directory=mock(SelfopOwnerDirectory.class);
var request=mock(InventoryTurnoverRequest.class);when(request.sourceAccount()).thenReturn("A");
var context=mock(InventoryTurnoverResponses.Context.class);var analytics=mock(AnalyticsResponses.Context.class);
var snapshot=mock(AnalyticsQueryService.Snapshot.class);var asOf=Instant.parse("2026-10-14T08:00:00Z");
when(context.queryAsOf()).thenReturn(asOf);when(context.analyticsContext()).thenReturn(analytics);when(context.dailySamples()).thenReturn(List.of());
when(contexts.resolve(request)).thenReturn(new InventoryTurnoverContext.Frozen(context,snapshot));
when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}")));
var service=new InventoryTurnoverQueryService(contexts,mapper,new ObjectMapper(),directory);
when(directory.names("A",asOf)).thenReturn(Map.of());assertEquals(0,service.query(request).total());
when(directory.names("A",asOf)).thenReturn(Map.of("C","货主名称"));assertEquals(0,service.query(request).total());
}
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopMasterDataMapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopPayloads;
import org.junit.jupiter.api.Test;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class SelfopOwnerDirectoryTest {
@Test void onlyPublishedBatchAtFrozenTimeSuppliesNames(){
var mapper=mock(SelfopMasterDataMapper.class);var time=Instant.parse("2026-10-14T08:00:00Z");
when(mapper.publishedAt("A",LocalDateTime.ofInstant(time,ZoneOffset.UTC))).thenReturn(11L);
when(mapper.owners("A",11L)).thenReturn(List.of(new SelfopPayloads.Owner("C","客户","0"),new SelfopPayloads.Owner("D","",null)));
var names=new SelfopOwnerDirectory(mapper).names("A",time);assertEquals("客户",names.get("C"));assertTrue(names.containsKey("D"));assertNull(names.get("D"));
assertEquals(Map.of(),new SelfopOwnerDirectory(mapper).names("B",time));verify(mapper,never()).owners("B",11L);
}
}
@@ -29,6 +29,25 @@ class SelfopEgressServiceTest {
private void success(EgressContext c,Operation op){service.authorize(new AuthorizationRequest(c,op,PROVIDER));service.complete(new CompletionRequest(c,op,PROVIDER,1,null,200,200,false,null));clock.advance(5);} private void success(EgressContext c,Operation op){service.authorize(new AuthorizationRequest(c,op,PROVIDER));service.complete(new CompletionRequest(c,op,PROVIDER,1,null,200,200,false,null));clock.advance(5);}
private SelfopApiBudget budget(){return SelfopSupport.read(account.getApiBudgetJson(),SelfopApiBudget.class);} private SelfopApiBudget budget(){return SelfopSupport.read(account.getApiBudgetJson(),SelfopApiBudget.class);}
private SelfopValidationBudget rootBudget(){return SelfopSupport.read(task.getValidationBudgetJson(),SelfopValidationBudget.class);} private SelfopValidationBudget rootBudget(){return SelfopSupport.read(task.getValidationBudgetJson(),SelfopValidationBudget.class);}
@Test void masterDataOperationsUseBoundBusinessTaskAndExistingSingleFlightGate(){
continuousBusinessRoot();task.setTaskType("MASTER_DATA");task.setQueryFromUtc(null);task.setQueryUntilUtc(null);
task.setPlannedAtUtc(now().plusHours(8).toLocalDate().atStartOfDay().minusHours(8));
var definition=new TreeMap<String,Object>();definition.put("dataFamily","MASTER_DATA");definition.put("sourceAccount","synthetic");
task.setQueryScopeJson(SelfopSupport.json(definition));task.setScopeHash(SelfopSupport.hash(definition));
task.setRequestKey("continuous:master:"+task.getPlannedAtUtc().plusHours(8).toLocalDate()+":"+task.getScopeHash().substring(0,12));
assertThat(SelfopCollectionPolicy.continuousBusinessRoot(task)).isTrue();
assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,100,null,null)).hasMessage("EGRESS_OPERATION_TASK_MISMATCH");
assertThatThrownBy(()->reserve(Operation.CUSTOMERS,1001,null,null)).hasMessage("EGRESS_OPERATION_TASK_MISMATCH");
assertThatThrownBy(()->reserve(Operation.PRODUCTS,101,null,null)).hasMessage("EGRESS_OPERATION_TASK_MISMATCH");
var c=reserve(Operation.CUSTOMERS,1000,null,null);
assertThatThrownBy(()->reserve(Operation.PRODUCTS,100,null,null)).isInstanceOf(EgressRejectedException.class);
success(c,Operation.CUSTOMERS);success(reserve(Operation.PRODUCTS,100,null,null),Operation.PRODUCTS);
assertThat(budget().confirmedHttp).isEqualTo(2);
}
@Test void ordinaryTasksCannotUseMasterDataOperations(){
business();assertThatThrownBy(()->reserve(Operation.CUSTOMERS,10,null,null)).hasMessage("EGRESS_OPERATION_TASK_MISMATCH");
assertThatThrownBy(()->reserve(Operation.PRODUCTS,10,null,null)).hasMessage("EGRESS_OPERATION_TASK_MISMATCH");
}
@Test void offUnknownModeUnknownEnvironmentBusinessAndUnverifiedHandoffFailBeforeAnyLock(){ @Test void offUnknownModeUnknownEnvironmentBusinessAndUnverifiedHandoffFailBeforeAnyLock(){
cfg.setCollectionMode("OFF");assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EGRESS_MODE_REJECTED"); cfg.setCollectionMode("OFF");assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EGRESS_MODE_REJECTED");
cfg.setCollectionMode("BUSINESS");cfg.setEnvironment("unknown");assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_ENVIRONMENT_REJECTED"); cfg.setCollectionMode("BUSINESS");cfg.setEnvironment("unknown");assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_ENVIRONMENT_REJECTED");
@@ -44,6 +44,12 @@ class SelfopContinuousDueSqlTest {
try(var rows=p.executeQuery()){var result=new ArrayList<Long>();while(rows.next())result.add(rows.getLong("id"));return result;} try(var rows=p.executeQuery()){var result=new ArrayList<Long>();while(rows.next())result.add(rows.getLong("id"));return result;}
} }
} }
@Test void masterCatalogIsDispatchableAfterInventoryAndHistoricalSales() throws Exception {
inventory(2,"READY",0,MIDNIGHT,null);
execute("INSERT INTO ll_selfop_sync_task VALUES(1,1,'SYNTH-ACCOUNT','BUSINESS',NULL,'READY','continuous:master:day','SELFOP_CONTINUOUS_PLANNER','ACCOUNT_DISCOVERY','MASTER_DATA',NULL,?,NULL,0)",MIDNIGHT.minusDays(1));
execute("INSERT INTO ll_selfop_sync_task VALUES(3,3,'SYNTH-ACCOUNT','BUSINESS',NULL,'READY','continuous:outbound:day','SELFOP_CONTINUOUS_PLANNER','ACCOUNT_DISCOVERY','BACKFILL',NULL,NULL,?,0)",MIDNIGHT.minusDays(10));
assertEquals(List.of(2L,3L,1L),due(MIDNIGHT));
}
@Test void retryAfterBeijingMidnightWaitsUntilBackoffThenRemainsDispatchable() throws Exception { @Test void retryAfterBeijingMidnightWaitsUntilBackoffThenRemainsDispatchable() throws Exception {
// Failure at 23:59:55 + the first 10-second backoff lands on the next Beijing day. // Failure at 23:59:55 + the first 10-second backoff lands on the next Beijing day.
inventory(1,"RETRY_WAIT",1,MIDNIGHT.minusHours(16),MIDNIGHT.plusSeconds(5)); inventory(1,"RETRY_WAIT",1,MIDNIGHT.minusHours(16),MIDNIGHT.plusSeconds(5));
@@ -20,7 +20,7 @@ class SelfopExecutionServiceTest {
when(leases.acquireScopes(any(),anyString())).thenReturn(java.util.Map.of(3L,lease));when(taskService.begin(1L,lease)).thenReturn(run); when(leases.acquireScopes(any(),anyString())).thenReturn(java.util.Map.of(3L,lease));when(taskService.begin(1L,lease)).thenReturn(run);
var candidates=mock(OutboundCandidateBuilder.class);var publisher=mock(SelfopSalesPublisher.class);var runs=mock(SelfopSyncRunMapper.class); var candidates=mock(OutboundCandidateBuilder.class);var publisher=mock(SelfopSalesPublisher.class);var runs=mock(SelfopSyncRunMapper.class);
when(candidates.reusable(anyLong(),anyLong(),anyString(),anyLong())).thenReturn(null); when(candidates.reusable(anyLong(),anyLong(),anyString(),anyLong())).thenReturn(null);
var service=new SelfopExecutionService(config,taskService,leases,mock(SelfopDiscoveryService.class),mock(SelfopInventoryCollector.class),mock(InventorySnapshotService.class),mock(OutboundFactService.class),candidates,publisher,scopes,tasks,mock(SelfopSyncStageMapper.class),mock(SelfopOutboundOrderMapper.class),mock(SelfopReferenceRecovery.class),mock(SelfopValidationRunner.class),runs); var service=new SelfopExecutionService(config,taskService,leases,mock(SelfopDiscoveryService.class),mock(SelfopInventoryCollector.class),mock(InventorySnapshotService.class),mock(OutboundFactService.class),candidates,publisher,scopes,tasks,mock(SelfopSyncStageMapper.class),mock(SelfopOutboundOrderMapper.class),mock(SelfopReferenceRecovery.class),mock(SelfopValidationRunner.class),runs,org.mockito.Mockito.mock(SelfopMasterDataCollector.class));
service.execute(1L); service.execute(1L);
var order=inOrder(candidates,publisher);order.verify(candidates).build(eq(3L),eq(2L),anyString(),eq(8L));order.verify(publisher).publish(eq(3L),eq(2L),anyString(),eq(8L)); var order=inOrder(candidates,publisher);order.verify(candidates).build(eq(3L),eq(2L),anyString(),eq(8L));order.verify(publisher).publish(eq(3L),eq(2L),anyString(),eq(8L));
verify(taskService,never()).waitForBudget(anyLong(),anyLong(),any(),any());verifyNoInteractions(runs); verify(taskService,never()).waitForBudget(anyLong(),anyLong(),any(),any());verifyNoInteractions(runs);
@@ -48,7 +48,7 @@ class SelfopExecutionServiceTest {
when(taskService.begin(1L,lease)).thenReturn(run);doThrow(failure).when(validation).run(task,run,lease); when(taskService.begin(1L,lease)).thenReturn(run);doThrow(failure).when(validation).run(task,run,lease);
var service=new SelfopExecutionService(config,taskService,leases,discovery,stock,snapshots,facts,candidates,publisher, var service=new SelfopExecutionService(config,taskService,leases,discovery,stock,snapshots,facts,candidates,publisher,
mock(SelfopSyncScopeMapper.class),tasks,mock(SelfopSyncStageMapper.class),mock(SelfopOutboundOrderMapper.class), mock(SelfopSyncScopeMapper.class),tasks,mock(SelfopSyncStageMapper.class),mock(SelfopOutboundOrderMapper.class),
recovery,validation,mock(SelfopSyncRunMapper.class)); recovery,validation,mock(SelfopSyncRunMapper.class),org.mockito.Mockito.mock(SelfopMasterDataCollector.class));
service.execute(1L); service.execute(1L);
verify(validation).fail(task,run,lease,code);verify(leases).release(lease); verify(validation).fail(task,run,lease,code);verify(leases).release(lease);
verify(taskService,never()).fail(anyLong(),anyLong(),anyString(),anyBoolean(),any()); verify(taskService,never()).fail(anyLong(),anyLong(),anyString(),anyBoolean(),any());
@@ -0,0 +1,86 @@
package com.baiyue.lastleg.selfoperated.analytics.sync;
import com.baiyue.lastleg.selfoperated.analytics.client.LingxingAnalyticsClient;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import org.junit.jupiter.api.*;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.dao.DuplicateKeyException;
import java.util.*;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class SelfopMasterDataCollectorTest {
final LingxingAnalyticsClient client=mock(LingxingAnalyticsClient.class);
final SelfopMasterDataMapper data=mock(SelfopMasterDataMapper.class);
final SelfopSyncTaskMapper tasks=mock(SelfopSyncTaskMapper.class);
final SelfopSyncRunMapper runs=mock(SelfopSyncRunMapper.class);
final SelfopTaskService service=mock(SelfopTaskService.class);
final SelfopLeaseService leases=mock(SelfopLeaseService.class);
final SelfopAnalyticsProperties config=new SelfopAnalyticsProperties();
final TransactionTemplate tx=mock(TransactionTemplate.class);
final SelfopSyncTask task=new SelfopSyncTask();final SelfopSyncRun run=new SelfopSyncRun();
final SelfopLeaseService.Lease lease=new SelfopLeaseService.Lease(1L,"owner",2L,true);
SelfopMasterDataCollector collector;
@BeforeEach void setup(){
config.setEnvironment("production");task.setId(10L);task.setTaskType("MASTER_DATA");task.setExecutionPurpose("BUSINESS");task.setSourceAccount("A");task.setTaskNo("LXCOL-DEMO");
run.setId(20L);run.setTaskId(10L);run.setExecutionPurpose("BUSINESS");run.setStatus("FETCHING");run.setAttemptNo(1);
when(service.checkpoint(task,run,lease)).thenReturn(new SelfopTaskService.CollectionCheckpoint(task,run));
doAnswer(i->{Consumer<TransactionStatus> c=i.getArgument(0);c.accept(mock(TransactionStatus.class));return null;}).when(tx).executeWithoutResult(any());
when(client.customers(any(),eq(1),eq(1000))).thenReturn(new LingxingAnalyticsClient.Page<>(List.of(new SelfopPayloads.Owner("C","客户","0")),1,1000,1L,1));
when(data.ownerCount(20L,"A")).thenReturn(1L);
collector=new SelfopMasterDataCollector(client,data,tasks,runs,service,leases,config,tx);
}
SelfopPayloads.Product product(String code,String sku){return new SelfopPayloads.Product(code,sku,"商品",null,null,null,"cm",null,null,null,null);}
@Test void publishesBothDirectoriesOnlyAfterBothCountsMatch(){
when(client.products(any(),eq(1),eq(100))).thenReturn(new LingxingAnalyticsClient.Page<>(List.of(product("C","SKU"),product("D","SKU")),1,100,2L,1));
when(data.productCount(20L,"A")).thenReturn(2L);
collector.collect(task,run,lease);
verify(client,never()).products(any(),anyInt(),anyInt());
collector.collect(task,run,lease);
assertEquals("PUBLISHED",run.getStatus());assertEquals("SUCCEEDED",task.getStatus());assertEquals(3L,run.getUniqueObjectCount());
verify(data,times(2)).insertProduct(anyLong(),eq(20L),eq("A"),any(),any());verify(service,times(1)).yield(anyLong(),anyLong(),any());
}
@Test void countMismatchCannotPublish(){
when(client.products(any(),eq(1),eq(100))).thenReturn(new LingxingAnalyticsClient.Page<>(List.of(product("C","SKU")),1,100,1L,1));
collector.collect(task,run,lease);
assertEquals("SELFOP_MASTER_COUNT_MISMATCH",assertThrows(IllegalStateException.class,()->collector.collect(task,run,lease)).getMessage());
assertNotEquals("PUBLISHED",run.getStatus());
}
@Test void duplicateFailsInsteadOfDeduplicatingPartialCatalog(){
doThrow(new DuplicateKeyException("duplicate")).when(data).insertOwner(anyLong(),anyLong(),anyString(),any(),any());
assertEquals("SELFOP_MASTER_DUPLICATE",assertThrows(IllegalStateException.class,()->collector.collect(task,run,lease)).getMessage());
verify(client,never()).products(any(),anyInt(),anyInt());
}
@Test void lostFenceCannotPersistLateResponse(){
when(service.checkpoint(task,run,lease)).thenThrow(new IllegalStateException("FENCE_LOST"));
assertThrows(IllegalStateException.class,()->collector.collect(task,run,lease));verifyNoInteractions(data);
}
@Test void yieldsAndResumesSameBatchWithoutRepeatingCustomerPage(){
for(int page=1;page<=6;page++){
var rows=new ArrayList<SelfopPayloads.Product>();for(int n=0;n<100;n++)rows.add(product("C","SKU-"+page+"-"+n));
when(client.products(any(),eq(page),eq(100))).thenReturn(new LingxingAnalyticsClient.Page<>(rows,page,100,600L,6));
}
when(data.productCount(20L,"A")).thenReturn(600L);
collector.collect(task,run,lease);verify(service).yield(10L,20L,lease);assertNotEquals("PUBLISHED",run.getStatus());
verify(client,never()).products(any(),anyInt(),anyInt());
for(int page=1;page<=6;page++){
collector.collect(task,run,lease);
verify(client,times(page)).products(any(),anyInt(),anyInt());
if(page<6)assertNotEquals("PUBLISHED",run.getStatus());
}
assertEquals("PUBLISHED",run.getStatus());verify(client,times(1)).customers(any(),eq(1),eq(1000));
verify(service,times(6)).yield(10L,20L,lease);
verify(data,times(600)).insertProduct(anyLong(),eq(20L),eq("A"),any(),any());
}
@Test void changedTotalRejectsBeforePersistence(){
var manifest=new SelfopMasterDataCollector.Manifest();
var rows=Collections.nCopies(100,product("C","SKU"));
SelfopMasterDataCollector.validatePage(manifest,new LingxingAnalyticsClient.Page<>(rows,1,100,201L,3),100);
manifest.setNextPage(2);
assertEquals("SELFOP_PAGE_TOTAL_CHANGED",assertThrows(IllegalStateException.class,()->SelfopMasterDataCollector.validatePage(manifest,new LingxingAnalyticsClient.Page<>(rows,2,100,202L,3),100)).getMessage());
}
}
@@ -16,6 +16,17 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
class SelfopSchedulePlannerTest { class SelfopSchedulePlannerTest {
@Test void masterCatalogIsOneAccountBatchAndAnUnfinishedBatchSpansDays(){
var state=continuousState();
planner.planContinuous(LocalDateTime.of(2026,10,13,4,0));
var master=state.stream().filter(t->"MASTER_DATA".equals(t.getTaskType())).findFirst().orElseThrow();
assertThat(SelfopCollectionPolicy.continuousBusinessRoot(master)).isTrue();assertThat(master.getWarehouseCode()).isNull();
planner.planContinuous(LocalDateTime.of(2026,10,14,4,0));
assertThat(state.stream().filter(t->"MASTER_DATA".equals(t.getTaskType()))).hasSize(1);
master.setStatus("SUCCEEDED");planner.planContinuous(LocalDateTime.of(2026,10,14,4,1));
planner.planContinuous(LocalDateTime.of(2026,10,14,4,2));
assertThat(state.stream().filter(t->"MASTER_DATA".equals(t.getTaskType()))).hasSize(2);
}
@Test void continuousInventoryWaitsForTodaysSlotAndNeverBackfillsPreviousDays(){ @Test void continuousInventoryWaitsForTodaysSlotAndNeverBackfillsPreviousDays(){
var state=continuousState(); var state=continuousState();
var manual7=dayRoot("RUNNING",7,"manual:7");var manual6=dayRoot("READY",6,"manual:6"); var manual7=dayRoot("RUNNING",7,"manual:7");var manual6=dayRoot("READY",6,"manual:6");
@@ -50,15 +61,15 @@ class SelfopSchedulePlannerTest {
@Test void continuousPoolPersistsCompleteDaysNewestFirstAndNeverChangesCoverageAnchor(){ @Test void continuousPoolPersistsCompleteDaysNewestFirstAndNeverChangesCoverageAnchor(){
var state=continuousState(); var state=continuousState();
planner.planContinuous(LocalDateTime.of(2026,10,13,4,0)); planner.planContinuous(LocalDateTime.of(2026,10,13,4,0));
assertThat(state).hasSize(62); assertThat(state).hasSize(63);
assertThat(state.get(0).getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,10,11,16,0)); assertThat(state.get(0).getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,10,11,16,0));
assertThat(state.get(60).getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,8,12,16,0)); assertThat(state.get(60).getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,8,12,16,0));
assertThat(state).allMatch(SelfopCollectionPolicy::continuousBusinessRoot); assertThat(state).allMatch(SelfopCollectionPolicy::continuousBusinessRoot);
verify(scopes,never()).updateById(any(SelfopSyncScope.class));verify(taskService,never()).initialize(); verify(scopes,never()).updateById(any(SelfopSyncScope.class));verify(taskService,never()).initialize();
planner.planContinuous(LocalDateTime.of(2026,10,13,4,1));assertThat(state).hasSize(62); planner.planContinuous(LocalDateTime.of(2026,10,13,4,1));assertThat(state).hasSize(63);
planner.planContinuous(LocalDateTime.of(2026,10,13,16,4));assertThat(state).hasSize(62); planner.planContinuous(LocalDateTime.of(2026,10,13,16,4));assertThat(state).hasSize(63);
planner.planContinuous(LocalDateTime.of(2026,10,13,16,5));assertThat(state).hasSize(63); planner.planContinuous(LocalDateTime.of(2026,10,13,16,5));assertThat(state).hasSize(64);
assertThat(state.get(62).getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,10,12,16,0)); assertThat(state.get(63).getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,10,12,16,0));
assertThat(state).anyMatch(t->LocalDateTime.of(2026,8,12,16,0).equals(t.getQueryFromUtc())); assertThat(state).anyMatch(t->LocalDateTime.of(2026,8,12,16,0).equals(t.getQueryFromUtc()));
} }
@Test void deadOldManualRootDoesNotMaskSuccessfulOrActiveDayAndContinuousFailureIsNotRecreated(){ @Test void deadOldManualRootDoesNotMaskSuccessfulOrActiveDayAndContinuousFailureIsNotRecreated(){
@@ -67,7 +78,7 @@ class SelfopSchedulePlannerTest {
state.add(dayRoot("RUNNING",11,"manual:running")); state.add(dayRoot("RUNNING",11,"manual:running"));
state.add(dayRoot("DEAD",10,"manual:failed-only")); state.add(dayRoot("DEAD",10,"manual:failed-only"));
planner.planContinuous(LocalDateTime.of(2026,10,13,4,0)); planner.planContinuous(LocalDateTime.of(2026,10,13,4,0));
assertThat(state.stream().filter(SelfopCollectionPolicy::continuousBusinessRoot)).hasSize(60); assertThat(state.stream().filter(SelfopCollectionPolicy::continuousBusinessRoot)).hasSize(61);
assertThat(state.stream().filter(SelfopCollectionPolicy::continuousBusinessRoot)).noneMatch(t->LocalDateTime.of(2026,10,11,16,0).equals(t.getQueryFromUtc())); assertThat(state.stream().filter(SelfopCollectionPolicy::continuousBusinessRoot)).noneMatch(t->LocalDateTime.of(2026,10,11,16,0).equals(t.getQueryFromUtc()));
var root=state.stream().filter(SelfopCollectionPolicy::continuousBusinessRoot).findFirst().orElseThrow();root.setStatus("DEAD"); var root=state.stream().filter(SelfopCollectionPolicy::continuousBusinessRoot).findFirst().orElseThrow();root.setStatus("DEAD");
int count=state.size();planner.planContinuous(LocalDateTime.of(2026,10,13,4,1));assertThat(state).hasSize(count); int count=state.size();planner.planContinuous(LocalDateTime.of(2026,10,13,4,1));assertThat(state).hasSize(count);
@@ -93,7 +104,12 @@ class SelfopSchedulePlannerTest {
return query.getParamNameValuePairs().containsValue("INVENTORY")?List.of(stock):List.of(scope);}); return query.getParamNameValuePairs().containsValue("INVENTORY")?List.of(stock):List.of(scope);});
var account=new SelfopOutboundDiscoveryScope();account.setId(30L);when(taskService.initializeAccount()).thenReturn(account); var account=new SelfopOutboundDiscoveryScope();account.setId(30L);when(taskService.initializeAccount()).thenReturn(account);
var state=new ArrayList<SelfopSyncTask>();when(tasks.selectList(any())).thenAnswer(call->new ArrayList<>(state)); var state=new ArrayList<SelfopSyncTask>();when(tasks.selectList(any())).thenAnswer(call->new ArrayList<>(state));
when(taskService.create(any())).thenAnswer(call->{SelfopSyncTask task=call.getArgument(0);state.add(task);return task;});return state; when(tasks.selectCount(any())).thenAnswer(call->{
com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<?> query=call.getArgument(0);query.getSqlSegment();
var values=query.getParamNameValuePairs().values();
return state.stream().filter(t->"MASTER_DATA".equals(t.getTaskType())).filter(t->values.contains("MASTER_DATA")?List.of("READY","RUNNING","RETRY_WAIT").contains(t.getStatus()):values.contains(t.getRequestKey())).count();
});
when(taskService.create(any())).thenAnswer(call->{SelfopSyncTask task=call.getArgument(0);task.setStatus("READY");state.add(task);return task;});return state;
} }
private SelfopSyncTask dayRoot(String status,int day,String key){ private SelfopSyncTask dayRoot(String status,int day,String key){
var root=controlled(100L+day,"ACCOUNT_DISCOVERY","BACKFILL",key);root.setDiscoveryScopeId(30L);root.setScopeHash("a".repeat(64));root.setStatus(status); var root=controlled(100L+day,"ACCOUNT_DISCOVERY","BACKFILL",key);root.setDiscoveryScopeId(30L);root.setScopeHash("a".repeat(64));root.setStatus(status);
@@ -326,6 +326,16 @@ class SelfopTaskServiceTest {
verifyNoInteractions(tasks, runs, scopes, accounts); verifyNoInteractions(tasks, runs, scopes, accounts);
} }
@Test void masterDataYieldPersistsFiveSecondsWithoutChangingOrdinaryScheduling() {
var task=task("RUNNING");task.setTaskType("MASTER_DATA");
var run=run("FETCHING");when(locks.lockTask(1L)).thenReturn(task);when(locks.lockRun(20L)).thenReturn(run);
service.yield(1L,20L,lease);
assertThat(task.getNextRetryAtUtc()).isEqualTo(task.getUpdatedAt().plusSeconds(5));
assertThat(run.getStatus()).isEqualTo("YIELDED");
task.setTaskType("BACKFILL");task.setStatus("RUNNING");run.setStatus("FETCHING");
service.yield(1L,20L,lease);
assertThat(task.getNextRetryAtUtc()).isEqualTo(task.getUpdatedAt().plusSeconds(1));
}
@Test void budgetYieldDoesNotBecomeFailedAttemptOrClearApplyBarrier() { @Test void budgetYieldDoesNotBecomeFailedAttemptOrClearApplyBarrier() {
var task = task("RUNNING"); var task = task("RUNNING");
task.setAttemptCount(99); task.setAttemptCount(99);