fix(selfop): retain turnover precision and scope reused publication runs
This commit is contained in:
+38
@@ -0,0 +1,38 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverResponses.*;
|
||||
|
||||
class InventoryTurnoverPolicyTest {
|
||||
static Context context() {
|
||||
return new Context(null,"demo","W",InventoryTurnoverSqlTest.END,30,null,null,null,null,null,null,List.of(),List.of(),null);
|
||||
}
|
||||
private Row mapped(String average,String turnover,int samples) {
|
||||
var raw=new HashMap<String,Object>();raw.put("average_physical",average);raw.put("turnover",turnover);raw.put("sample_count",samples);
|
||||
raw.put("w30_outbound",BigDecimal.ONE);raw.put("w30_complete",30);
|
||||
return InventoryTurnoverPolicy.row(raw,InventoryTurnoverSqlTest.request("SKU","ALL","TURNOVER_DESC",null,null,1,20),context());
|
||||
}
|
||||
@Test void tinyPositiveAverageQualifiesBeforeSixPlaceOutputRounding() {
|
||||
var row=mapped("0.0000000333","30000000",30);
|
||||
assertEquals("VALID",row.turnoverStatus());assertTrue(row.sampleReasonCodes().isEmpty());
|
||||
assertEquals(new BigDecimal("0.000000"),row.averagePhysicalQuantity());
|
||||
assertEquals(new BigDecimal("30000000.000000"),row.quantityTurnoverCount());
|
||||
}
|
||||
@Test void zeroUnknownAndIncompleteSamplesCannotLeakATurnoverValue() {
|
||||
for(var input:List.of(new String[]{"0","ZERO_AVERAGE_STOCK"},new String[]{null,"INSUFFICIENT"})) {
|
||||
var row=mapped(input[0],"30000000",30);assertNull(row.quantityTurnoverCount());
|
||||
assertEquals(input[1],row.turnoverStatus());assertEquals(List.of(input[1]),row.sampleReasonCodes());
|
||||
}
|
||||
var row=mapped("1","30000000",29);assertNull(row.quantityTurnoverCount());
|
||||
assertEquals("INSUFFICIENT",row.turnoverStatus());assertEquals(List.of("INSUFFICIENT"),row.sampleReasonCodes());
|
||||
}
|
||||
@Test void positiveAverageSeparatesMissingSalesFromKnownZeroSales() {
|
||||
var missing=mapped("0.0000000333",null,30);assertNull(missing.quantityTurnoverCount());
|
||||
assertEquals("SALES_WINDOW_UNAVAILABLE",missing.turnoverStatus());assertEquals(List.of("SALES_WINDOW_UNAVAILABLE"),missing.sampleReasonCodes());
|
||||
var zero=mapped("0.0000000333","0",30);assertEquals("VALID",zero.turnoverStatus());
|
||||
assertEquals(new BigDecimal("0.000000"),zero.quantityTurnoverCount());
|
||||
}
|
||||
}
|
||||
+59
-1
@@ -362,12 +362,70 @@ class InventoryTurnoverSqlTest {
|
||||
assertEquals("SKU".equals(group)?0:3,report.stream().filter(r->"ROW".equals(r.get("section"))).count());
|
||||
}
|
||||
}
|
||||
@Test void tinyQualifiedDailySumUsesOneDivisionForSkuAndOwnerThroughProductionCtes() throws Exception {
|
||||
seed("A","ONE",30);seed("A","TWO",30);
|
||||
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE WHEN snapshot_run_id=100 AND product_quality=0 THEN CASE WHEN sku_code='ONE' THEN 0.000001 ELSE 0.000002 END ELSE 0 END WHERE snapshot_run_id>=100");
|
||||
execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE WHEN business_date=DATE '"+END+"' THEN CASE WHEN sku_code='ONE' THEN 1 ELSE 2 END ELSE 0 END WHERE warehouse_code='W'");
|
||||
for(String group:List.of("SKU","OWNER")) {
|
||||
var q=request(group,"ALL","TURNOVER_DESC",null,null,1,20);var p=input(q,30,30,true);
|
||||
for(var row:reportRows(p)) {
|
||||
assertTrue(new BigDecimal(row.get("average_physical").toString()).signum()>0);
|
||||
decimal("30000000",row.get("turnover"));decimal("30",row.get("sample_count"));
|
||||
var mapped=InventoryTurnoverPolicy.row(row,q,InventoryTurnoverPolicyTest.context());
|
||||
assertEquals("VALID",mapped.turnoverStatus());assertTrue(mapped.sampleReasonCodes().isEmpty());
|
||||
assertEquals(new BigDecimal("0.000000"),mapped.averagePhysicalQuantity());
|
||||
assertEquals(new BigDecimal("30000000.000000"),mapped.quantityTurnoverCount());
|
||||
}
|
||||
for(var row:query("page",p))decimal("30000000",row.get("turnover"));
|
||||
}
|
||||
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=0 WHERE snapshot_run_id>=100");
|
||||
for(String group:List.of("SKU","OWNER")) {
|
||||
var q=request(group,"ALL","TURNOVER_DESC",null,null,1,20);
|
||||
for(var row:reportRows(input(q,30,30,true))) {
|
||||
decimal("0",row.get("average_physical"));assertNull(row.get("turnover"));
|
||||
var mapped=InventoryTurnoverPolicy.row(row,q,InventoryTurnoverPolicyTest.context());
|
||||
assertEquals("ZERO_AVERAGE_STOCK",mapped.turnoverStatus());assertNull(mapped.quantityTurnoverCount());
|
||||
assertEquals(List.of("ZERO_AVERAGE_STOCK"),mapped.sampleReasonCodes());
|
||||
}
|
||||
for(var row:reportRows(input(q,30,29,true))) {
|
||||
assertNull(row.get("average_physical"));assertNull(row.get("turnover"));
|
||||
var mapped=InventoryTurnoverPolicy.row(row,q,InventoryTurnoverPolicyTest.context());
|
||||
assertEquals("INSUFFICIENT",mapped.turnoverStatus());assertNull(mapped.quantityTurnoverCount());
|
||||
}
|
||||
}
|
||||
}
|
||||
@Test void unroundedThresholdAndStableTiesSurviveActualReportPagination() throws Exception {
|
||||
seed("OA","HIGH",30);seed("OA","A",30);seed("OZ","A",30);seed("OA","LOW",30);
|
||||
execute("UPDATE ll_selfop_inventory_snapshot SET available_quantity=CASE sku_code WHEN 'HIGH' THEN 200.000001 WHEN 'LOW' THEN 199.999999 ELSE 200 END WHERE snapshot_run_id=22 AND product_quality=0");
|
||||
var gt=request("SKU","COVERAGE_GT","COVERAGE_DESC",null,20,1,20);
|
||||
var greater=reportRows(input(gt,30,30,true));assertEquals(List.of("HIGH"),greater.stream().map(r->r.get("sku_code")).toList());
|
||||
assertEquals(new BigDecimal("20.000000"),InventoryTurnoverPolicy.row(greater.get(0),gt,InventoryTurnoverPolicyTest.context()).inventoryCoverageDays());
|
||||
var le=request("SKU","COVERAGE_LE","COVERAGE_DESC",null,20,1,20);
|
||||
assertEquals(List.of("A/OA","A/OZ","LOW/OA"),reportRows(input(le,30,30,true)).stream().map(r->r.get("sku_code")+"/"+r.get("customer_code")).toList());
|
||||
var expected=List.of("HIGH/OA","A/OA","A/OZ","LOW/OA");
|
||||
for(int page=1;page<=4;page++) {
|
||||
var q=request("SKU","ALL","COVERAGE_DESC",null,20,page,1);var result=reportRows(input(q,30,30,true));
|
||||
assertEquals(1,result.size());assertEquals(expected.get(page-1),result.get(0).get("sku_code")+"/"+result.get(0).get("customer_code"));
|
||||
}
|
||||
}
|
||||
@Test void turnoverOrderingUsesUnroundedRatioBeforeStableSkuAndOwnerPages() throws Exception {
|
||||
seed("Z","HIGH",30);seed("A","TIE",30);seed("B","TIE",30);seed("L","LOW",30);
|
||||
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE WHEN product_quality=0 THEN 10 ELSE 0 END WHERE snapshot_run_id>=100");
|
||||
execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE WHEN business_date=DATE '"+END+"' THEN CASE sku_code WHEN 'HIGH' THEN 10.000001 WHEN 'LOW' THEN 9.999999 ELSE 10 END ELSE 0 END WHERE warehouse_code='W'");
|
||||
for(String group:List.of("SKU","OWNER")) for(int page=1;page<=4;page++) {
|
||||
var q=request(group,"ALL","TURNOVER_DESC",null,null,page,1);
|
||||
var result=reportRows(input(q,30,30,true));assertEquals(1,result.size());
|
||||
assertEquals(List.of("Z","A","B","L").get(page-1),result.get(0).get("customer_code"));
|
||||
var mapped=InventoryTurnoverPolicy.row(result.get(0),q,InventoryTurnoverPolicyTest.context());
|
||||
assertEquals("VALID",mapped.turnoverStatus());assertEquals(new BigDecimal("1.000000"),mapped.quantityTurnoverCount());
|
||||
}
|
||||
}
|
||||
private static com.fasterxml.jackson.databind.JsonNode payload(Map<String,Object> row) throws Exception {
|
||||
var json=new com.fasterxml.jackson.databind.ObjectMapper().enable(com.fasterxml.jackson.databind.DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS);Object raw=row.get("payload");
|
||||
return raw instanceof byte[] bytes?json.readTree(bytes):json.readTree(raw.toString());
|
||||
}
|
||||
private List<Map<String,Object>> reportRows(InventoryTurnoverSql.Input p) throws Exception {
|
||||
var result=new ArrayList<Map<String,Object>>();var json=new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
var result=new ArrayList<Map<String,Object>>();var json=new com.fasterxml.jackson.databind.ObjectMapper().enable(com.fasterxml.jackson.databind.DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS);
|
||||
for(var row:query("report",p)) if("ROW".equals(row.get("section")))
|
||||
result.add(json.convertValue(payload(row),new com.fasterxml.jackson.core.type.TypeReference<Map<String,Object>>(){}));
|
||||
return result;
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.sync;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopCoordinationMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.ibatis.mapping.BoundSql;
|
||||
import org.apache.ibatis.mapping.MappedStatement;
|
||||
import org.apache.ibatis.scripting.defaults.DefaultParameterHandler;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import java.sql.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** Executes production JOIN/predicates/assignments offline. Only MySQL UPDATE JOIN syntax is
|
||||
* adapted to H2 MERGE; this does not prove MySQL optimizer choices or lock isolation. */
|
||||
public class SelfopReusedRunScopeSqlTest {
|
||||
Connection db; MappedStatement statement; BoundSql bound;
|
||||
final Map<String,Object> params=Map.of("scope",7L,"account","SYNTH-ACCOUNT","warehouse","SYNTH-A",
|
||||
"hash","scope-hash","published",900L,"now",LocalDateTime.of(2026,10,14,0,0));
|
||||
|
||||
@BeforeEach void setup() throws Exception {
|
||||
db=DriverManager.getConnection("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE","sa","");
|
||||
execute("CREATE TABLE ll_selfop_sync_task(id BIGINT PRIMARY KEY,scope_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,scope_hash VARCHAR,result_published_run_id BIGINT,attempt_count INT,execution_purpose VARCHAR,progress_json VARCHAR)");
|
||||
execute("CREATE TABLE ll_selfop_sync_run(id BIGINT PRIMARY KEY,task_id BIGINT,scope_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,scope_hash VARCHAR,attempt_no INT,execution_purpose VARCHAR,status VARCHAR,finished_at_utc TIMESTAMP,updated_at TIMESTAMP,progress_snapshot_json VARCHAR)");
|
||||
execute("CREATE ALIAS JSON_EXTRACT FOR '"+getClass().getName()+".jsonExtract'");
|
||||
var config=new Configuration();config.addMapper(SelfopCoordinationMapper.class);
|
||||
statement=config.getMappedStatement(SelfopCoordinationMapper.class.getName()+".completeReusedRuns");
|
||||
bound=statement.getBoundSql(params);
|
||||
}
|
||||
@AfterEach void close() throws Exception {db.close();}
|
||||
public static String jsonExtract(String body,String path) throws Exception {
|
||||
if(body==null)return null;assertEquals("$.attemptNo",path);
|
||||
var value=new ObjectMapper().readTree(body).get("attemptNo");return value==null?null:value.asText();
|
||||
}
|
||||
void execute(String sql,Object...args) throws Exception {
|
||||
try(var p=db.prepareStatement(sql)){for(int n=0;n<args.length;n++)p.setObject(n+1,args[n]);p.executeUpdate();}
|
||||
}
|
||||
void pair(long id,String status) throws Exception {
|
||||
execute("INSERT INTO ll_selfop_sync_task VALUES (?,7,'SYNTH-ACCOUNT','SYNTH-A','scope-hash',900,2,'BUSINESS',?)",id+1000,"{\"attemptNo\":2,\"checkpoint\":\"kept\"}");
|
||||
execute("INSERT INTO ll_selfop_sync_run VALUES (?,?,7,'SYNTH-ACCOUNT','SYNTH-A','scope-hash',2,'BUSINESS',?,NULL,NULL,NULL)",id,id+1000,status);
|
||||
}
|
||||
Map<String,Object> row(long id) throws Exception {
|
||||
try(var p=db.prepareStatement("SELECT * FROM ll_selfop_sync_run WHERE id=?")){p.setLong(1,id);try(var r=p.executeQuery()){assertTrue(r.next());var out=new TreeMap<String,Object>();for(int n=1;n<=r.getMetaData().getColumnCount();n++)out.put(r.getMetaData().getColumnLabel(n),r.getObject(n));return out;}}
|
||||
}
|
||||
int update() throws Exception {
|
||||
String sql=bound.getSql().replaceAll("\\s+"," ").trim();
|
||||
int set=sql.indexOf(" SET "),where=sql.indexOf(" WHERE ");assertTrue(sql.startsWith("UPDATE ")&&set>0&&where>set);
|
||||
String from=sql.substring(7,set),predicate=sql.substring(where+7);
|
||||
// Assignment order preserves MyBatis parameter order. No test-owned selection predicate.
|
||||
var projection=new ArrayList<String>();var assignments=new ArrayList<String>();
|
||||
for(String assignment:sql.substring(set+5,where).split(",(?=r\\.)")){
|
||||
int equal=assignment.indexOf('=');String column=assignment.substring(0,equal).replace("r.","");
|
||||
String expression=assignment.substring(equal+1);
|
||||
// H2 cannot infer a bare parameter's type in a derived table; production columns are timestamps.
|
||||
if(expression.equals("?")){assertTrue(column.equals("finished_at_utc")||column.equals("updated_at"));expression="CAST(? AS TIMESTAMP)";}
|
||||
projection.add(expression+" AS "+column);assignments.add("destination."+column+"=source."+column);
|
||||
}
|
||||
String adapted="MERGE INTO ll_selfop_sync_run destination USING (SELECT r.id,"+String.join(",",projection)+" FROM "+from+" WHERE "+predicate+") source ON destination.id=source.id WHEN MATCHED THEN UPDATE SET "+String.join(",",assignments);
|
||||
try(var p=db.prepareStatement(adapted)){new DefaultParameterHandler(statement,params,bound).setParameters(p);return p.executeUpdate();}
|
||||
}
|
||||
@Test void nonemptyMatchingAttemptsCompleteAllFourEligibleStatusesAndKeepProgress() throws Exception {
|
||||
String[] statuses={"FETCHING","FACTS_APPLIED","BUILDING","READY"};
|
||||
for(int n=0;n<statuses.length;n++)pair(11+n,statuses[n]);
|
||||
execute("UPDATE ll_selfop_sync_run SET progress_snapshot_json=? WHERE id=11","{\"frozen\":true}");
|
||||
pair(900,"READY");var publishedBefore=row(900);
|
||||
assertEquals(4,update());assertEquals(publishedBefore,row(900),"publishing run must not become REUSED");
|
||||
for(int n=0;n<4;n++){var r=row(11+n);assertEquals("REUSED",r.get("status"));assertEquals(Timestamp.valueOf((LocalDateTime)params.get("now")),r.get("finished_at_utc"));assertEquals(r.get("finished_at_utc"),r.get("updated_at"));assertEquals(n==0?"{\"frozen\":true}":"{\"attemptNo\":2,\"checkpoint\":\"kept\"}",r.get("progress_snapshot_json"));}
|
||||
assertEquals(0,update(),"already completed attempts are untouched");
|
||||
}
|
||||
@ParameterizedTest @ValueSource(strings={"scope_id","source_account","warehouse_code","scope_hash"})
|
||||
void eitherSideOutsidePublishedScopeIsUntouchedWhileValidSiblingCompletes(String column) throws Exception {
|
||||
pair(11,"READY");pair(12,"READY");pair(13,"READY");pair(14,"READY");
|
||||
Object wrong=column.equals("scope_id")?8L:"different";
|
||||
execute("UPDATE ll_selfop_sync_run SET "+column+"=? WHERE id=12",wrong);
|
||||
execute("UPDATE ll_selfop_sync_task SET "+column+"=? WHERE id=1013",wrong);
|
||||
execute("UPDATE ll_selfop_sync_run SET "+column+"=? WHERE id=14",wrong);
|
||||
execute("UPDATE ll_selfop_sync_task SET "+column+"=? WHERE id=1014",wrong);
|
||||
var before=Map.of(12L,row(12),13L,row(13),14L,row(14));
|
||||
assertEquals(1,update());assertEquals("REUSED",row(11).get("status"));
|
||||
for(var item:before.entrySet())assertEquals(item.getValue(),row(item.getKey()),"foreign association must not be changed: "+column);
|
||||
}
|
||||
@ParameterizedTest @ValueSource(strings={"old-attempt","wrong-result","run-validation","task-validation","PUBLISHED","FAILED","DEAD","REUSED","null-scope"})
|
||||
void existingPurposeAttemptPointerAndStatusGatesRemain(String fault) throws Exception {
|
||||
pair(11,"READY");pair(12,"READY");
|
||||
switch(fault){
|
||||
case "old-attempt" -> execute("UPDATE ll_selfop_sync_run SET attempt_no=1 WHERE id=12");
|
||||
case "wrong-result" -> execute("UPDATE ll_selfop_sync_task SET result_published_run_id=901 WHERE id=1012");
|
||||
case "run-validation" -> execute("UPDATE ll_selfop_sync_run SET execution_purpose='VALIDATION' WHERE id=12");
|
||||
case "task-validation" -> execute("UPDATE ll_selfop_sync_task SET execution_purpose='VALIDATION' WHERE id=1012");
|
||||
case "null-scope" -> execute("UPDATE ll_selfop_sync_task SET scope_id=NULL WHERE id=1012");
|
||||
default -> execute("UPDATE ll_selfop_sync_run SET status=? WHERE id=12",fault);
|
||||
}
|
||||
var before=row(12);assertEquals(1,update());assertEquals("REUSED",row(11).get("status"));assertEquals(before,row(12));
|
||||
}
|
||||
@Test void mismatchedProgressAttemptIsNotCopiedIntoCompletedRun() throws Exception {
|
||||
pair(11,"READY");execute("UPDATE ll_selfop_sync_task SET progress_json=? WHERE id=1011","{\"attemptNo\":1}");
|
||||
assertEquals(1,update());assertEquals("REUSED",row(11).get("status"));assertNull(row(11).get("progress_snapshot_json"));
|
||||
}
|
||||
}
|
||||
+3
@@ -122,6 +122,9 @@ class SelfopSalesPublisherTest {
|
||||
ordered.verify(runs).update(isNull(),any());
|
||||
ordered.verify(scopes).updateById(any(SelfopSyncScope.class));
|
||||
ordered.verify(runs).updateById(any(SelfopSyncRun.class)); ordered.verify(tasks).updateById(any(SelfopSyncTask.class));
|
||||
ordered.verify(locks).completeCoveredTasks(eq(1L),eq(scope.getScopeHash()),eq(10L),eq(run.getCoveredFromUtc()),eq(run.getCoveredUntilUtc()),any());
|
||||
ordered.verify(locks).completeReusedRuns(eq(1L),eq(scope.getSourceAccount()),eq(scope.getWarehouseCode()),eq(scope.getScopeHash()),eq(10L),any());
|
||||
ordered.verify(locks).completeCoveredMarkers(eq(1L),eq(scope.getScopeHash()),eq(10L),eq(run.getCoveredUntilUtc()),any());
|
||||
assertNotNull(SelfopSalesPublisher.class.getMethod("publish",long.class,long.class,String.class,long.class).getAnnotation(Transactional.class));
|
||||
assertEquals(10L,task.getResultPublishedRunId());assertNotNull(run.getProgressSnapshotJson());
|
||||
// Publisher constructor has no inventory repositories or inventory publication prerequisite.
|
||||
|
||||
Reference in New Issue
Block a user