From 72d7ed696255908826c7167d083cd29b3ece8bd7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 15 Oct 2026 10:29:42 +0800 Subject: [PATCH 1/7] feat(selfop): hide known zero inventory before turnover pagination --- .../query/InventoryTurnoverRequest.java | 5 +- .../analytics/query/InventoryTurnoverSql.java | 1 + .../self-operated-inventory-turnover-v1.yaml | 3 +- ...atformInventoryTurnoverControllerTest.java | 4 +- .../query/InventoryTurnoverContextTest.java | 12 ++--- .../query/InventoryTurnoverRequestTest.java | 4 +- .../query/InventoryTurnoverSqlTest.java | 47 ++++++++++++++----- 7 files changed, 52 insertions(+), 24 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java index af69a9088..5832aa4de 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java @@ -10,12 +10,13 @@ public record InventoryTurnoverRequest(String sourceAccount, String warehouseCod @DateTimeFormat(iso=DateTimeFormat.ISO.DATE) LocalDate endDate, Integer baseWindowDays, String groupBy, String customerCode, String sku, String focus, Integer coverageDaysThreshold, String turnoverStatus, String sortBy, Integer current, Integer size, Boolean includeOverview, - String snapshotContext) { + String snapshotContext, Boolean hideZeroInventory) { private static final Set PARAMETERS=Set.of("sourceAccount","warehouseCode","endDate","baseWindowDays", - "groupBy","customerCode","sku","focus","coverageDaysThreshold","turnoverStatus","sortBy","current","size","includeOverview","snapshotContext"); + "groupBy","customerCode","sku","focus","coverageDaysThreshold","turnoverStatus","sortBy","current","size","includeOverview","snapshotContext","hideZeroInventory"); public static void validateParameters(Set keys) { if(!PARAMETERS.containsAll(keys)) throw new BizException("库存周转不接受其他视图的查询参数"); } + public boolean hideZero(){return !Boolean.FALSE.equals(hideZeroInventory);} public int days(){return baseWindowDays==null?30:baseWindowDays;} public java.util.List windows(){return java.util.stream.Stream.of(7,15,30,days()).distinct().sorted().toList();} public String group(){return groupBy==null?"SKU":groupBy;} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java index 8510a8244..9ed24bed7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java @@ -209,6 +209,7 @@ public final class InventoryTurnoverSql { case "UNASSESSABLE" -> "unassessable>0"; default -> "1=1"; }; + if(p.query.hideZero()) predicate+=" AND (physical IS NULL OR physical<>0)"; if(p.query.turnoverStatus()!=null) predicate+=" AND sample_count<#{p.query.days}"; if(!owner&&p.query.sku()!=null) predicate+=" AND sku_code=#{p.query.sku}"; return " WHERE "+predicate; diff --git a/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml index f6eff8b98..07a03d506 100644 --- a/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml @@ -19,7 +19,7 @@ paths: operationId: inventoryTurnover summary: 单仓SKU或货主三窗口库存周转 description: >- - 精确customerCode限定列表与概览;sku、focus及turnoverStatus只筛列表。 + 精确customerCode限定列表与概览;sku、focus、turnoverStatus及hideZeroInventory只筛列表。 按完整候选计算依赖后排序分页,未知末尾,同值按SKU及货主或货主编码稳定排序。 OWNER不计算整体可售天数;销售事实完整即可展示货主实际出库,不受单SKU观察期影响。 SKU出库及无出库判断同样不受首次观察时间限制:可信完整覆盖的缺销量行可计零;实际异常单位或未覆盖日期保持未知。 @@ -34,6 +34,7 @@ paths: - {name: groupBy, in: query, schema: {type: string, enum: [SKU, OWNER], default: SKU}} - {name: customerCode, in: query, schema: {type: string, minLength: 1, maxLength: 64}} - {name: sku, in: query, description: SKU分组的精确查询,OWNER禁止传入, schema: {type: string, minLength: 1, maxLength: 128}} + - {name: hideZeroInventory, in: query, description: 默认隐藏当前全品质实物件数明确为0的SKU或货主汇总行,保留未知;全量筛选后分页,不改变概览及图表, schema: {type: boolean, default: true}} - name: focus in: query schema: {type: string, enum: [ALL, NO_OUTBOUND_IN_WINDOW, COVERAGE_GT, COVERAGE_LE, FROZEN, DEFECTIVE, UNASSESSABLE], default: ALL} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryTurnoverControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryTurnoverControllerTest.java index bf9bc058a..50f37f8e9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryTurnoverControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryTurnoverControllerTest.java @@ -44,9 +44,9 @@ class PlatformInventoryTurnoverControllerTest { .param("sourceAccount","demo").param("warehouseCode","W").param("endDate","2026-10-12") .param("baseWindowDays","15").param("groupBy","OWNER").param("focus","COVERAGE_GT") .param("coverageDaysThreshold","60").param("current","2").param("size","20") - .param("snapshotContext","fixed-token").param("includeOverview","false")).andExpect(status().isOk()); + .param("hideZeroInventory","false").param("snapshotContext","fixed-token").param("includeOverview","false")).andExpect(status().isOk()); var capture=ArgumentCaptor.forClass(InventoryTurnoverRequest.class);verify(service).query(capture.capture()); var q=capture.getValue();assertEquals(LocalDate.of(2026,10,12),q.endDate());assertEquals(15,q.days()); - assertEquals("OWNER",q.group());assertEquals(60,q.coverageDaysThreshold());assertEquals(20,q.offset());assertFalse(q.overview());assertEquals("fixed-token",q.snapshotContext()); + assertEquals("OWNER",q.group());assertEquals(60,q.coverageDaysThreshold());assertEquals(20,q.offset());assertFalse(q.overview());assertFalse(q.hideZero());assertEquals("fixed-token",q.snapshotContext()); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java index 3e7847240..1f511b19d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java @@ -35,7 +35,7 @@ class InventoryTurnoverContextTest { assertEquals(END.minusDays(29),first.analyticsContext().rangeStartDate()); assertEquals(END.plusDays(1),first.analyticsContext().rangeEndDateExclusive()); assertEquals(first,at(NOW.plusSeconds(60)).resolve(request(first.snapshotContext())).response()); - var page2=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,20,false,first.snapshotContext()); + var page2=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,20,false,first.snapshotContext(),false); var second=at(NOW.plusSeconds(90)).resolve(page2); assertEquals(first,second.response()); assertEquals(InventoryTurnoverSql.input(request(first.snapshotContext()),contexts.resolve(request(first.snapshotContext()))).days(), @@ -47,11 +47,11 @@ class InventoryTurnoverContextTest { assertThrows(BizException.class,()->contexts.resolve(request(first.snapshotContext()))); } @Test void fullYearContextRemainsSmallAndReconstructsEveryMissingDate() { - var q=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",1,20,true,null); + var q=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",1,20,true,null,false); var first=contexts.resolve(q).response(); assertEquals(366,first.dailySamples().size());assertEquals(365,first.missingSampleDates().size()); assertTrue(first.snapshotContext().length()<2048); - var next=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,20,false,first.snapshotContext()); + var next=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,20,false,first.snapshotContext(),false); assertEquals(first,at(NOW.plusSeconds(60)).resolve(next).response()); when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of()); assertThrows(BizException.class,()->contexts.resolve(next)); @@ -62,7 +62,7 @@ class InventoryTurnoverContextTest { ((com.fasterxml.jackson.databind.node.ObjectNode)evidence).put("salesRun",999L); String tampered=Base64.getUrlEncoder().encodeToString(json.writeValueAsBytes(evidence)); assertThrows(BizException.class,()->contexts.resolve(request(tampered))); - var another=new InventoryTurnoverRequest("demo","OTHER",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token); + var another=new InventoryTurnoverRequest("demo","OTHER",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token,false); assertThrows(BizException.class,()->contexts.resolve(another)); assertThrows(BizException.class,()->at(NOW.plusSeconds(1800)).resolve(request(token))); assertThrows(BizException.class,()->at(NOW.minusSeconds(1)).resolve(request(token))); @@ -138,11 +138,11 @@ class InventoryTurnoverContextTest { verify(mapper,never()).page(any());verify(mapper,never()).count(any());verify(mapper,never()).overview(any());verify(mapper,never()).owners(any()); clearInvocations(mapper); when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}"))); - 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(),false); 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),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,false);} 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 Run run(long id,long scope,String hash){ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequestTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequestTest.java index 4632dd4be..5a0f364a7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequestTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequestTest.java @@ -16,7 +16,9 @@ class InventoryTurnoverRequestTest { } @Test void defaultsToYesterdayThirtyDaysAndFirstPageOverview() { var q=request(Map.of("includeOverview",false));q.validate(TODAY); - assertEquals(TODAY.minusDays(1),q.end(TODAY));assertEquals(30,q.days());assertEquals("SKU",q.group());assertEquals(20,q.pageSize());assertTrue(q.overview()); + assertEquals(TODAY.minusDays(1),q.end(TODAY));assertEquals(30,q.days());assertEquals("SKU",q.group());assertEquals(20,q.pageSize());assertTrue(q.overview());assertTrue(q.hideZero()); + assertFalse(request(Map.of("hideZeroInventory",false)).hideZero()); + assertTrue(request(Map.of("hideZeroInventory",true)).hideZero()); } @Test void rejectsIncompleteDatesFiltersSortsAndUnpinnedPagination() { for(var invalid:List.of(Map.of("endDate","2026-10-13"),Map.of("baseWindowDays",367), diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java index eb2654a15..99b5e0768 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java @@ -33,6 +33,29 @@ class InventoryTurnoverSqlTest { private void product(String owner,String sku,String declared,String measured) throws Exception { execute("INSERT INTO ll_selfop_product VALUES(901,'demo','"+owner+"','"+sku+"',"+declared+","+measured+")"); } + @Test void hidesOnlyKnownZeroPhysicalRowsBeforeCountAndPagingWithoutChangingOverview() throws Exception { + seed("A","POSITIVE",0);seed("A","ZERO",0);seed("B","ZERO",0);seed("C","UNKNOWN",0); + execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=0 WHERE snapshot_run_id=22 AND sku_code='ZERO'"); + execute("UPDATE ll_selfop_inventory_snapshot SET available_quantity=0 WHERE snapshot_run_id=22 AND customer_code='A'"); + execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=NULL,quality_status='UNKNOWN' WHERE snapshot_run_id=22 AND customer_code='C'"); + for(String group:List.of("SKU","OWNER")) { + var all=new InventoryTurnoverRequest("demo","W",END,30,group,null,null,"ALL",null,null,"PHYSICAL_ASC",1,20,true,"fixed",false); + var hidden=new InventoryTurnoverRequest("demo","W",END,30,group,null,null,"ALL",null,null,"PHYSICAL_ASC",1,20,true,"fixed",null); + var visible=reportRows(input(hidden,30,0,true)); + assertEquals(List.of("A","C"),visible.stream().map(r->r.get("customer_code")).toList()); + assertEquals(group.equals("SKU")?4:3,reportRows(input(all,30,0,true)).size()); + var report=query("report",input(hidden,30,0,true)); + assertEquals(2,payload(report.stream().filter(r->"TOTAL".equals(r.get("section"))).findFirst().orElseThrow()).path("total").asInt()); + assertEquals(query("overview",input(all,30,0,true)),query("overview",input(hidden,30,0,true))); + for(int page=1;page<=2;page++) { + var paged=new InventoryTurnoverRequest("demo","W",END,30,group,null,null,"ALL",null,null,"PHYSICAL_ASC",page,1,false,"fixed",true); + assertEquals(visible.get(page-1).get("customer_code"),reportRows(input(paged,30,0,true)).get(0).get("customer_code")); + } + if(group.equals("SKU"))assertEquals(4,query("page",input(hidden,30,0,false)).size(),"Stale stock stays unknown, not zero"); + } + var exact=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,"ZERO","ALL",null,null,"PHYSICAL_DESC",1,20,true,"fixed",true); + assertTrue(reportRows(input(exact,30,0,true)).isEmpty()); + } @Test void ownerVolumeChartRanksFullTotalsExcludesUnknownsAndIgnoresListFilters() throws Exception { master(); for(int i=0;i<12;i++) { @@ -43,7 +66,7 @@ class InventoryTurnoverSqlTest { product("UNKNOWN","SAME","100,1,1,'m'","NULL,NULL,NULL,NULL"); execute("UPDATE ll_selfop_inventory_snapshot SET quality_status='INVALID' WHERE customer_code='UNKNOWN'"); for(int days:List.of(7,30)) { - var q=new InventoryTurnoverRequest("demo","W",END,days,"SKU",null,"not-present","FROZEN",null,null,"PHYSICAL_DESC",2,1,true,"fixed"); + var q=new InventoryTurnoverRequest("demo","W",END,days,"SKU",null,"not-present","FROZEN",null,null,"PHYSICAL_DESC",2,1,true,"fixed",false); var report=query("report",input(q,30,0,true)); var ranking=new ArrayList(); for(var part:report)if("OWNER_VOLUME".equals(part.get("section")))ranking.add(payload(part)); @@ -78,7 +101,7 @@ class InventoryTurnoverSqlTest { : direction.equals("ASC")?List.of("A","D","B","C"):List.of("B","A","D","C"); var actual=new ArrayList(); for(int page=1;page<=2;page++) { - var q=new InventoryTurnoverRequest("demo","W",END,days,group,null,null,"ALL",null,null,metric+"_"+direction,page,2,true,"fixed"); + var q=new InventoryTurnoverRequest("demo","W",END,days,group,null,null,"ALL",null,null,metric+"_"+direction,page,2,true,"fixed",false); q.validate(END.plusDays(1)); actual.addAll(reportRows(input(q,30,0,true)).stream().map(r->r.get("customer_code")).toList()); } @@ -142,7 +165,7 @@ class InventoryTurnoverSqlTest { decimal("1",reportRows(later).get(0).get("unit_volume")); } static InventoryTurnoverRequest request(String group,String focus,String sort,String sku,Integer threshold,int page,int size) { - return new InventoryTurnoverRequest("demo","W",END,30,group,null,sku,focus,threshold,null,sort,page,size,true,null); + return new InventoryTurnoverRequest("demo","W",END,30,group,null,sku,focus,threshold,null,sort,page,size,true,null,false); } static InventoryTurnoverSql.Input input(InventoryTurnoverRequest q,int covered,int samples,boolean fresh) { List days=new ArrayList<>(); @@ -153,7 +176,7 @@ class InventoryTurnoverSqlTest { seed("A","SKU",1);master();product("A","SKU","10,10,10,'cm'","NULL,NULL,NULL,NULL"); execute("UPDATE ll_selfop_sku_profile SET earliest_evidence_at_utc=TIMESTAMP '2025-01-01 00:00:00'"); for(int n:List.of(3,45,366)) { - var q=new InventoryTurnoverRequest("demo","W",END,n,"SKU",null,null,"ALL",null,null,null,1,20,true,null); + var q=new InventoryTurnoverRequest("demo","W",END,n,"SKU",null,null,"ALL",null,null,null,1,20,true,null,false); var dates=new ArrayList(); int horizon=Math.max(30,n); for(int i=0;i=30-n&&!present)missing.add(date.toString()); } - var q=new InventoryTurnoverRequest("demo","W",END,n,group,null,null,"ALL",null,null,null,1,20,true,null); + var q=new InventoryTurnoverRequest("demo","W",END,n,group,null,null,"ALL",null,null,null,1,20,true,null,false); var p=new InventoryTurnoverSql.Input(q,11,22,true,true,List.copyOf(dates),LocalDateTime.of(2026,10,13,0,0)); var row=query("page",p).get(0); assertNotNull(row.get("sample_mask")); @@ -368,7 +391,7 @@ class InventoryTurnoverSqlTest { // Both edge bits and a middle bit, with different missing members per day. execute("DELETE FROM ll_selfop_inventory_snapshot WHERE warehouse_code='W' AND ((sku_code='ONE' AND snapshot_run_id IN (100,114)) OR (sku_code='TWO' AND snapshot_run_id=129))"); for(int n:List.of(7,15,30)) { - var q=new InventoryTurnoverRequest("demo","W",END,n,"OWNER",null,null,"ALL",null,null,null,1,20,true,null); + var q=new InventoryTurnoverRequest("demo","W",END,n,"OWNER",null,null,"ALL",null,null,null,1,20,true,null,false); var p=input(q,30,30,true); var row=reportRows(p).get(0); var expected=new ArrayList(); @@ -378,7 +401,7 @@ class InventoryTurnoverSqlTest { assertNull(row.get("average_physical"));assertNull(row.get("turnover")); assertEquals(row.get("missing_dates"),query("page",p).get(0).get("missing_dates")); } - var pageTwo=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,2,1,false,null); + var pageTwo=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,2,1,false,null,false); var second=reportRows(input(pageTwo,30,30,true)).get(0); assertEquals("TWO",second.get("sku_code"));decimal("29",second.get("sample_count"));assertEquals(END.toString(),second.get("missing_dates")); // A stock/profile-only candidate without samples invalidates all owner days. @@ -396,7 +419,7 @@ class InventoryTurnoverSqlTest { List days=new ArrayList<>(); for(int i=0;i<30;i++){var date=END.minusDays(29-i);days.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),1,first+i));} for(int n:List.of(7,15,30)) { - var q=new InventoryTurnoverRequest("demo","W",END,n,"SKU",null,null,"ALL",null,null,null,1,20,true,null); + var q=new InventoryTurnoverRequest("demo","W",END,n,"SKU",null,null,"ALL",null,null,null,1,20,true,null,false); var p=new InventoryTurnoverSql.Input(q,11,22,true,true,List.copyOf(days),LocalDateTime.of(2026,10,13,0,0)); var row=reportRows(p).get(0); decimal(String.valueOf(n),row.get("sample_count")); @@ -412,12 +435,12 @@ class InventoryTurnoverSqlTest { @Test void reportPaginationKeepsTotalAndCanOmitOverview() throws Exception { seed("A","SKU",30);seed("B","SKU",30); var first=reportRows(input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,1),30,30,true)); - var secondQuery=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,1,false,"frozen"); + var secondQuery=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,1,false,"frozen",false); var second=query("report",input(secondQuery,30,30,true)); var row=second.stream().filter(r->"ROW".equals(r.get("section"))).findFirst().orElseThrow(); assertNotEquals(first.get(0).get("customer_code"),payload(row).path("customer_code").asText()); assertEquals(2,second.size()); - var beyond=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",3,1,false,"frozen"); + var beyond=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",3,1,false,"frozen",false); var report=query("report",input(beyond,30,30,true));assertEquals(1,report.size()); assertEquals("TOTAL",report.get(0).get("section"));assertEquals(2,payload(report.get(0)).path("total").asInt()); } @@ -665,7 +688,7 @@ class InventoryTurnoverSqlTest { execute("UPDATE ll_selfop_sku_daily_sales SET quantity_unit='UNKNOWN',outbound_quantity=9999 WHERE sku_code='UNKNOWN'"); for(int days:List.of(7,15,30)) { for(int page=1;page<=3;page++) { - var q=new InventoryTurnoverRequest("demo","W",END,days,"SKU",null,null,"ALL",null,null,"AVERAGE_DAILY_DESC",page,1,true,"fixed-context"); + var q=new InventoryTurnoverRequest("demo","W",END,days,"SKU",null,null,"ALL",null,null,"AVERAGE_DAILY_DESC",page,1,true,"fixed-context",false); var expected=days==7?List.of("RECENT","OLDER","UNKNOWN"):List.of("OLDER","RECENT","UNKNOWN"); assertEquals(expected.get(page-1),reportRows(input(q,30,0,true)).get(0).get("sku_code")); } From e19d303a769bc0e59084a58c5d88f111d4772e17 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 15 Oct 2026 11:12:44 +0800 Subject: [PATCH 2/7] feat(selfop): filter turnover by multiple owners across all aggregates --- .../query/InventoryTurnoverRequest.java | 9 ++++--- .../analytics/query/InventoryTurnoverSql.java | 24 +++++++++++-------- .../self-operated-inventory-turnover-v1.yaml | 4 ++-- ...atformInventoryTurnoverControllerTest.java | 2 ++ .../query/InventoryTurnoverRequestTest.java | 7 ++++++ .../query/InventoryTurnoverSqlTest.java | 20 ++++++++++++++++ 6 files changed, 51 insertions(+), 15 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java index 5832aa4de..663037d63 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java @@ -8,11 +8,11 @@ import org.springframework.format.annotation.DateTimeFormat; /** Separate from DETAIL: its dates, windows and filters must never be silently mixed. */ public record InventoryTurnoverRequest(String sourceAccount, String warehouseCode, @DateTimeFormat(iso=DateTimeFormat.ISO.DATE) LocalDate endDate, Integer baseWindowDays, - String groupBy, String customerCode, String sku, String focus, Integer coverageDaysThreshold, + String groupBy, java.util.List customerCodes, String sku, String focus, Integer coverageDaysThreshold, String turnoverStatus, String sortBy, Integer current, Integer size, Boolean includeOverview, String snapshotContext, Boolean hideZeroInventory) { private static final Set PARAMETERS=Set.of("sourceAccount","warehouseCode","endDate","baseWindowDays", - "groupBy","customerCode","sku","focus","coverageDaysThreshold","turnoverStatus","sortBy","current","size","includeOverview","snapshotContext","hideZeroInventory"); + "groupBy","customerCodes","sku","focus","coverageDaysThreshold","turnoverStatus","sortBy","current","size","includeOverview","snapshotContext","hideZeroInventory"); public static void validateParameters(Set keys) { if(!PARAMETERS.containsAll(keys)) throw new BizException("库存周转不接受其他视图的查询参数"); } @@ -29,7 +29,10 @@ public record InventoryTurnoverRequest(String sourceAccount, String warehouseCod public LocalDate end(LocalDate today){return endDate==null?today.minusDays(1):endDate;} public void validate(LocalDate today) { AnalyticsQuery.text(sourceAccount,64,"来源账号"); AnalyticsQuery.text(warehouseCode,64,"仓库"); - if(customerCode!=null) AnalyticsQuery.text(customerCode,64,"货主"); + if(customerCodes!=null) { + if(customerCodes.size()>100) throw new BizException("最多选择100个货主"); + for(String code:customerCodes) AnalyticsQuery.text(code,64,"货主"); + } if(sku!=null) AnalyticsQuery.text(sku,128,"SKU"); if(days()<1||days()>366) throw new BizException("统计区间须为1至366个完整自然日"); if(!end(today).isBefore(today)) throw new BizException("截止日必须是北京时间已结束的日期"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java index 9ed24bed7..ab1e4ffc8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java @@ -36,9 +36,13 @@ public final class InventoryTurnoverSql { "VALID".equals(frozen.response().analyticsContext().inventoryStatus()),frozen.snapshot().sourceKnown(),days,LocalDateTime.ofInstant(frozen.response().queryAsOf(),ZoneOffset.UTC)); } private static String windowStart(int n){return "#{p.from"+(java.util.Set.of(7,15,30).contains(n)?Integer.toString(n):"Base")+"}";} - private static String scope(String alias) { - return alias+"source_account=#{p.query.sourceAccount} AND "+alias+"warehouse_code=#{p.query.warehouseCode}" - +" AND (#{p.query.customerCode,jdbcType=VARCHAR} IS NULL OR "+alias+"customer_code=#{p.query.customerCode,jdbcType=VARCHAR})"; + private static String scope(Input p,String alias) { + String base=alias+"source_account=#{p.query.sourceAccount} AND "+alias+"warehouse_code=#{p.query.warehouseCode}"; + var owners=p.query.customerCodes(); + if(owners==null||owners.isEmpty())return base; + var values=new ArrayList(); + for(int i=0;i=0 AND (quality_status='VALID' OR quality_reason IN ('FIELD_MISSING','NEGATIVE_QUANTITY'))";} private static String amount(String field){return "CASE WHEN MIN(CASE WHEN "+valid(field)+" THEN 1 ELSE 0 END)=1 THEN SUM("+field+") END";} @@ -56,27 +60,27 @@ public final class InventoryTurnoverSql { runs.add("#{p.days["+i+"].sampleRun,jdbcType=BIGINT}"); return runs.isEmpty()?"NULL":String.join(",",runs); } - private static String salesDays() { + private static String salesDays(Input p) { return "SELECT customer_code,sku_code,business_date,SUM(outbound_quantity) quantity,MIN(CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END) unit_valid FROM ll_selfop_sku_daily_sales WHERE " - +scope("")+" AND sales_run_id=#{p.salesRun} AND business_date>=CAST(#{p.from} AS DATE) AND business_date=CAST(#{p.from} AS DATE) AND business_date0 AND quantity_unit='PCS' GROUP BY customer_code,sku_code)"); + .append(scope(p,"")).append(" AND sales_run_id=#{p.salesRun} AND business_date0 AND quantity_unit='PCS' GROUP BY customer_code,sku_code)"); sql.append(", sku_base AS (SELECT c.customer_code,c.sku_code,c.product_name,c.evidence,c.unit_valid,pv.unit_volume,COALESCE(pv.dimension_source,'MISSING') dimension_source"); for(int n:p.query().windows()) for(String suffix:List.of("outbound","observed","complete","partial")) sql.append(",w.w").append(n).append('_').append(suffix); sql.append(",w.sample_count,w.sample_total,w.average_physical,w.sample_mask,ls.last_outbound"); @@ -254,7 +258,7 @@ public final class InventoryTurnoverSql { +"SELECT 'OWNER' kind,customer_code,physical,0 unknown_count FROM ranked_owners UNION ALL SELECT 'META' kind,NULL customer_code,NULL physical,COUNT(*) unknown_count FROM owner_result WHERE physical IS NULL ORDER BY kind DESC,physical DESC,customer_code"; } public static String chartWindows(Map args) { - Input p=(Input)args.get("p");return "WITH calendar AS ("+calendar(p)+"), sales_days AS ("+salesDays()+"), warehouse_days AS (SELECT d.business_date," + Input p=(Input)args.get("p");return "WITH calendar AS ("+calendar(p)+"), sales_days AS ("+salesDays(p)+"), warehouse_days AS (SELECT d.business_date," +"CASE WHEN #{p.salesKnown}=1 AND d.coverage=1 AND MIN(COALESCE(s.unit_valid,1))=1 THEN 1 ELSE 0 END complete," +"CASE WHEN #{p.salesKnown}=1 THEN CASE WHEN COUNT(s.business_date)=0 AND d.coverage>0 THEN 0 ELSE SUM(CASE WHEN s.unit_valid=1 THEN s.quantity END) END END observed " +"FROM calendar d LEFT JOIN sales_days s ON s.business_date=d.business_date GROUP BY d.business_date,d.coverage), warehouse_window_counts AS (SELECT COUNT(*) day_count"+windowAggregates(p)+" FROM warehouse_days) SELECT w.*"+formalWindows(p)+" FROM warehouse_window_counts w"; diff --git a/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml index 07a03d506..d2481ebf9 100644 --- a/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml @@ -19,7 +19,7 @@ paths: operationId: inventoryTurnover summary: 单仓SKU或货主三窗口库存周转 description: >- - 精确customerCode限定列表与概览;sku、focus、turnoverStatus及hideZeroInventory只筛列表。 + customerCodes按所选货主集合限定列表、概览及图表,省略或空集合表示全部;sku、focus、turnoverStatus及hideZeroInventory只筛列表。 按完整候选计算依赖后排序分页,未知末尾,同值按SKU及货主或货主编码稳定排序。 OWNER不计算整体可售天数;销售事实完整即可展示货主实际出库,不受单SKU观察期影响。 SKU出库及无出库判断同样不受首次观察时间限制:可信完整覆盖的缺销量行可计零;实际异常单位或未覆盖日期保持未知。 @@ -32,7 +32,7 @@ paths: - {name: endDate, in: query, description: '含截止日,默认北京时间昨天,不接受今日或未来', schema: {type: string, format: date}} - {name: baseWindowDays, in: query, schema: {type: integer, minimum: 1, maximum: 366, default: 30}} - {name: groupBy, in: query, schema: {type: string, enum: [SKU, OWNER], default: SKU}} - - {name: customerCode, in: query, schema: {type: string, minLength: 1, maxLength: 64}} + - {name: customerCodes, in: query, style: form, explode: true, description: '货主编码多选,重复键传输;同一编码不重复计数,最多100个', schema: {type: array, maxItems: 100, items: {type: string, minLength: 1, maxLength: 64}}} - {name: sku, in: query, description: SKU分组的精确查询,OWNER禁止传入, schema: {type: string, minLength: 1, maxLength: 128}} - {name: hideZeroInventory, in: query, description: 默认隐藏当前全品质实物件数明确为0的SKU或货主汇总行,保留未知;全量筛选后分页,不改变概览及图表, schema: {type: boolean, default: true}} - name: focus diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryTurnoverControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryTurnoverControllerTest.java index 50f37f8e9..5f768d94a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryTurnoverControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryTurnoverControllerTest.java @@ -43,10 +43,12 @@ class PlatformInventoryTurnoverControllerTest { mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/inventory-turnover") .param("sourceAccount","demo").param("warehouseCode","W").param("endDate","2026-10-12") .param("baseWindowDays","15").param("groupBy","OWNER").param("focus","COVERAGE_GT") + .param("customerCodes","A","B") .param("coverageDaysThreshold","60").param("current","2").param("size","20") .param("hideZeroInventory","false").param("snapshotContext","fixed-token").param("includeOverview","false")).andExpect(status().isOk()); var capture=ArgumentCaptor.forClass(InventoryTurnoverRequest.class);verify(service).query(capture.capture()); var q=capture.getValue();assertEquals(LocalDate.of(2026,10,12),q.endDate());assertEquals(15,q.days()); + assertEquals(List.of("A","B"),q.customerCodes()); assertEquals("OWNER",q.group());assertEquals(60,q.coverageDaysThreshold());assertEquals(20,q.offset());assertFalse(q.overview());assertFalse(q.hideZero());assertEquals("fixed-token",q.snapshotContext()); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequestTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequestTest.java index 5a0f364a7..1ada14f91 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequestTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequestTest.java @@ -20,6 +20,13 @@ class InventoryTurnoverRequestTest { assertFalse(request(Map.of("hideZeroInventory",false)).hideZero()); assertTrue(request(Map.of("hideZeroInventory",true)).hideZero()); } + @Test void validatesMultipleOwnersAndRejectsTheRemovedSingleOwnerParameter() { + request(Map.of("customerCodes",List.of("A","B"))).validate(TODAY); + request(Map.of("customerCodes",List.of())).validate(TODAY); + assertThrows(BizException.class,()->request(Map.of("customerCodes",List.of(""))).validate(TODAY)); + assertThrows(BizException.class,()->request(Map.of("customerCodes",Collections.nCopies(101,"A"))).validate(TODAY)); + assertThrows(BizException.class,()->InventoryTurnoverRequest.validateParameters(Set.of("customerCode"))); + } @Test void rejectsIncompleteDatesFiltersSortsAndUnpinnedPagination() { for(var invalid:List.of(Map.of("endDate","2026-10-13"),Map.of("baseWindowDays",367), Map.of("groupBy","OWNER","sku","S"),Map.of("groupBy","OWNER","sortBy","COVERAGE_DESC"), diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java index 99b5e0768..60c841e25 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java @@ -56,6 +56,26 @@ class InventoryTurnoverSqlTest { var exact=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,"ZERO","ALL",null,null,"PHYSICAL_DESC",1,20,true,"fixed",true); assertTrue(reportRows(input(exact,30,0,true)).isEmpty()); } + @Test void filtersMultipleOwnersBeforeAggregationAndPaginationWithoutMergingSameSku() throws Exception { + seed("A","SAME",30);seed("B","SAME",30);seed("C","SAME",30); + for(String group:List.of("SKU","OWNER")) { + var selected=new InventoryTurnoverRequest("demo","W",END,30,group,List.of("B","A","A"),null,"ALL",null,null,"PHYSICAL_DESC",1,20,true,"fixed",false); + var p=input(selected,30,30,true); + var rows=reportRows(p); + assertEquals(List.of("A","B"),rows.stream().map(r->r.get("customer_code")).toList()); + var all=input(new InventoryTurnoverRequest("demo","W",END,30,group,null,null,"ALL",null,null,"PHYSICAL_DESC",1,20,true,"fixed",false),30,30,true); + assertEquals(3,reportRows(all).size()); + for(int page=1;page<=2;page++) { + var q=new InventoryTurnoverRequest("demo","W",END,30,group,List.of("A","B"),null,"ALL",null,null,"PHYSICAL_DESC",page,1,false,"fixed",false); + assertEquals(rows.get(page-1).get("customer_code"),reportRows(input(q,30,30,true)).get(0).get("customer_code")); + } + var overview=query("overview",p); + assertFalse(overview.isEmpty()); + assertNotEquals(query("overview",all),overview); + var unknown=new InventoryTurnoverRequest("demo","W",END,30,group,List.of("' OR 1=1 --"),null,"ALL",null,null,"PHYSICAL_DESC",1,20,true,"fixed",false); + assertTrue(reportRows(input(unknown,30,30,true)).isEmpty()); + } + } @Test void ownerVolumeChartRanksFullTotalsExcludesUnknownsAndIgnoresListFilters() throws Exception { master(); for(int i=0;i<12;i++) { From d81d9cbda4c80dcc65001c45e83d8234495c92a5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 15 Oct 2026 11:37:49 +0800 Subject: [PATCH 3/7] feat(selfop): add bounded inventory flow diagnostic through account egress --- .../LingxingInternalController.java | 9 ++ .../dto/lingxing/LingxingEgressDtos.java | 2 +- .../dto/lingxing/LingxingWmsDtos.java | 6 + .../provider/lingxing/LingxingWmsClient.java | 27 +++++ .../resources/openapi/lingxing-wms-v1.yaml | 28 ++++- .../lingxing/LingxingWmsClientTest.java | 32 ++++++ .../client/RestLingxingAnalyticsClient.java | 11 +- .../PlatformInventoryFlowProbeController.java | 23 ++++ .../mapper/SelfopCoordinationMapper.java | 2 +- .../analytics/request/SelfopEgressDtos.java | 2 +- .../request/SelfopEgressService.java | 5 + .../sync/InventoryFlowProbeService.java | 103 ++++++++++++++++++ .../openapi/self-operated-analytics-v1.yaml | 28 +++++ ...tformInventoryFlowProbeControllerTest.java | 22 ++++ .../request/SelfopEgressServiceTest.java | 15 +++ .../sync/InventoryFlowProbeServiceTest.java | 55 ++++++++++ 16 files changed, 365 insertions(+), 5 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryFlowProbeController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/InventoryFlowProbeService.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryFlowProbeControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/InventoryFlowProbeServiceTest.java diff --git a/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java b/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java index ec969e957..166a1c26b 100644 --- a/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java +++ b/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java @@ -78,6 +78,15 @@ public class LingxingInternalController { return ApiResponse.success(client.queryDetail(request, context(context))); } + @Operation(summary = "单SKU最多31日库存流水诊断,仅第一页最多1000条") + @PostMapping("/inventory-flow/query") + public ApiResponse> inventoryFlow( + @RequestHeader(value="X-Internal-Service-Token",required=false) String token, + @RequestHeader(value="X-Lingxing-Egress-Context",required=false) String context, + @Valid @RequestBody InventoryFlowRequest request) { + tokenGuard.verify(token);return ApiResponse.success(client.queryInventoryFlow(request,context(context))); + } + @Operation(summary = "读取单仓全部货主、全部品质且保留零库存的产品库存单页") @PostMapping("/product-inventory/query") public ApiResponse> inventory( diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressDtos.java b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressDtos.java index e9749ff69..70b95d999 100644 --- a/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressDtos.java +++ b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressDtos.java @@ -6,7 +6,7 @@ import java.time.Instant; public final class LingxingEgressDtos { private LingxingEgressDtos() { } public enum CollectionMode { OFF, VALIDATION, BUSINESS } - public enum Operation { WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY, CUSTOMERS, PRODUCTS } + public enum Operation { WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY, CUSTOMERS, PRODUCTS, INVENTORY_FLOW } public record EgressContext(String sourceAccount, CollectionMode executionPurpose, String rootTaskId, String sessionId, String attemptId, String exclusiveGrantId) { public boolean wellFormed() { diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java index 2a0eefeae..379c617eb 100644 --- a/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java +++ b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java @@ -20,6 +20,12 @@ public final class LingxingWmsDtos { 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 InventoryFlowRequest(@NotBlank @Size(max=64) String warehouseCode, + @NotBlank @Size(max=64) String customerCode, @NotBlank @Size(max=128) String sku, + @NotNull LocalDateTime startTime, @NotNull LocalDateTime endTime) { } + public record InventoryFlow(String id, String customerCode, String whCode, String productSku, + String cellNo, String innerBatchNo, String billNo, String billType, Integer productQuality, + BigDecimal bookQty, String bookChangQty, BigDecimal closeQty, String createTime) { } 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, diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java index 401ddb3cf..b74d26299 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java @@ -162,6 +162,30 @@ public final class LingxingWmsClient { (data, metadata) -> inventory(data, request, metadata)); } + /** Single bounded diagnostic page; never changes the current-inventory collection protocol. */ + public QueryPage queryInventoryFlow(InventoryFlowRequest request, EgressContext context) { + if(request==null)throw badRequest(); + requireInput(request.warehouseCode(),64);requireInput(request.customerCode(),64);requireInput(request.sku(),128); + if(request.startTime()==null||request.endTime()==null||!request.startTime().isBefore(request.endTime()) + ||request.endTime().isAfter(request.startTime().plusDays(31)))throw badRequest(); + Map body=pagination(1,1000); + body.put("whCode",request.warehouseCode());body.put("customerCode",request.customerCode()); + body.put("barcodeAccurate",request.sku());body.put("startTime",SOURCE_TIME.format(request.startTime())); + body.put("endTime",SOURCE_TIME.format(request.endTime()));body.put("sortName","createTime");body.put("sortOrder","asc"); + return execute("/openapi/v2/stock/stockCellBook/page",body,context,Operation.INVENTORY_FLOW, + (data,metadata)->mappedPage(data,1,1000,"current","size",false,metadata,row->{ + if(!request.warehouseCode().equals(requiredText(row,"whCode",64)) + ||!request.customerCode().equals(requiredText(row,"customerCode",64)) + ||!request.sku().equals(requiredText(row,"productSku",128)))throw invalidResponse("FLOW_IDENTITY_MISMATCH",200); + JsonNode id=row.get("id"); + if(id==null||!id.isIntegralNumber())throw invalidResponse("FLOW_ID_MISSING",200); + return new InventoryFlow(id.asText(),request.customerCode(),request.warehouseCode(),request.sku(), + optionalText(row,"cellNo"),optionalText(row,"innerBatchNo"),optionalText(row,"billNo"),optionalText(row,"billType"), + requiredInt(row.get("productQuality"),"PRODUCT_QUALITY"),quantity(row.get("bookQty")), + optionalText(row,"bookChangQty"),quantity(row.get("closeQty")),optionalText(row,"createTime")); + })); + } + private QueryPage inventory(JsonNode data, InventoryQueryRequest request, AttemptMetadata metadata) { Set> keys = new HashSet<>(); return mappedPage(data, request.current(), request.size(), "current", "size", false, metadata, row -> { @@ -203,6 +227,9 @@ public final class LingxingWmsClient { JsonNode json; try { json = envelope(response.body()); } catch (LingxingWmsException error) { + // This optional endpoint may lack permission while ordinary collection remains authorized. + if(operation==Operation.INVENTORY_FLOW&&Integer.valueOf(11008).equals(error.sourceCode())) + throw upstreamFailure("FLOW_PERMISSION_DENIED","当前账号没有库存流水接口权限",false,200,11008); if (Integer.valueOf(31001).equals(error.sourceCode()) || Integer.valueOf(429).equals(error.sourceCode())) { handle.rateLimited(retryAfter(response)); } diff --git a/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml b/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml index 7c327a5a1..99281d72c 100644 --- a/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml +++ b/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml @@ -51,6 +51,32 @@ x-lastleg-egress-coordination: security: - InternalServiceToken: [] paths: + /internal/integrations/lingxing/wms/inventory-flow/query: + post: + summary: One SKU inventory-flow diagnostic page + description: >- + Calls stock/stockCellBook/page through the existing INVENTORY_FLOW permit. Exact warehouse/customer/SKU, + at most 31 days, current=1 and size=1000 fixed. No automatic next page or retry. Source quality and signed + quantities are preserved, not translated to inventory-snapshot quality. Source 11008 only rejects this optional + operation; authentication, rate-limit and uncertain-result account protection remain effective. + parameters: + - {$ref: '#/components/parameters/LingxingEgressContext'} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [warehouseCode, customerCode, sku, startTime, endTime] + properties: + warehouseCode: {type: string, maxLength: 64} + customerCode: {type: string, maxLength: 64} + sku: {type: string, maxLength: 128} + startTime: {type: string, format: date-time, description: Beijing local datetime without offset} + endTime: {type: string, format: date-time, description: Beijing local datetime without offset} + responses: + '200': + description: Standard envelope with QueryPage or sanitized ProviderFailure. Flow rows retain id as string, customerCode, whCode, productSku, cellNo, innerBatchNo, billNo, billType, productQuality, numeric bookQty/closeQty, string bookChangQty/createTime. Unknown source fields remain null; no calculated inventory is published. /internal/integrations/lingxing/wms/customers/query: post: summary: Query one page of customers using the existing account egress permit @@ -315,7 +341,7 @@ components: {"current":1,"size":2,"whCode":"WH-DEMO","isHideInventory":0} EgressOperation: type: string - enum: [WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY, CUSTOMERS, PRODUCTS] + enum: [WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY, CUSTOMERS, PRODUCTS, INVENTORY_FLOW] EgressMetadata: type: object required: [attemptId, actualHttpAttempts, accountPaused] diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java index 5b1c917f7..e27c0a72c 100644 --- a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java @@ -311,6 +311,38 @@ class LingxingWmsClientTest { response.set(page("current", "size", 1, 1, 1, 1, ORDER).replace("\"code\":200", "\"code\":200,\"success\":" + success)); assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context())); } + private InventoryFlowRequest flow() {return new InventoryFlowRequest("W-DEMO","C-DEMO","SKU-DEMO", + LocalDateTime.of(2026,9,14,8,0),LocalDateTime.of(2026,10,14,8,0));} + @Test void flowUsesExactScopeSinglePageAndPreservesSignedChangeAndLargeId() throws Exception { + response.set(page("current","size",1,1000,1,1,""" + {"id":9223372036854775806,"customerCode":"C-DEMO","whCode":"W-DEMO","productSku":"SKU-DEMO", + "productQuality":1,"bookQty":-3,"bookChangQty":"-3","closeQty":17,"createTime":"2026-10-13 12:00:00"} + """)); + var result=client().queryInventoryFlow(flow(),context()); + assertThat(path.get()).isEqualTo("/openapi/v2/stock/stockCellBook/page"); + var sent=mapper.readTree(body.get()).path("data"); + assertThat(sent.path("barcodeAccurate").asText()).isEqualTo("SKU-DEMO"); + assertThat(sent.path("size").asInt()).isEqualTo(1000);assertThat(sent.has("productQuality")).isFalse(); + assertThat(result.records().get(0).id()).isEqualTo("9223372036854775806"); + assertThat(result.records().get(0).bookQty()).isEqualByComparingTo("-3"); + assertThat(result.records().get(0).bookChangQty()).isEqualTo("-3");assertThat(calls.get()).isEqualTo(1); + assertThat(completion.get().operation()).isEqualTo(Operation.INVENTORY_FLOW); + } + @Test void flowPermissionDenialIsScopedAndDoesNotStopOrdinaryQueries() { + var c=client();response.set("{\"code\":11008,\"msg\":\"private\"}"); + var error=assertThrows(LingxingWmsException.class,()->c.queryInventoryFlow(flow(),context())); + assertThat(error.code()).isEqualTo("LINGXING_WMS_FLOW_PERMISSION_DENIED"); + assertThat(completion.get().accountPaused()).isFalse(); + response.set(page("current","size",1,1,1,1,ORDER)); + assertThat(c.queryPage(query(1,1),context()).records()).hasSize(1);assertThat(calls.get()).isEqualTo(2); + } + @Test void flowRejectsUnboundedWindowBeforeSendingAndWrongSkuAfterResponse() { + var q=flow();var c=client(); + assertThrows(LingxingWmsException.class,()->c.queryInventoryFlow(new InventoryFlowRequest(q.warehouseCode(),q.customerCode(),q.sku(),q.startTime(),q.startTime().plusDays(32)),context())); + assertThat(calls.get()).isZero(); + response.set(page("current","size",1,1000,1,1,"{\"whCode\":\"W-DEMO\",\"customerCode\":\"C-DEMO\",\"productSku\":\"OTHER\"}")); + contract(()->c.queryInventoryFlow(q,context()),"FLOW_IDENTITY_MISMATCH"); + } private LingxingWmsClient client() { return client(CollectionMode.BUSINESS, true, true); } private LingxingWmsClient client(CollectionMode mode, boolean grant, boolean budget) { var clock = new LingxingEgressGateTest.MutableClock(NOW); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java index 5c343f238..45f780ca7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java @@ -33,6 +33,7 @@ import java.util.function.Function; public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient { private static final String PREFIX = "/internal/integrations/lingxing/wms"; private static final Set PROVIDER_ERROR_CODES = Set.of( + "LINGXING_WMS_FLOW_PERMISSION_DENIED", "LINGXING_WMS_AUTHORIZATION_REJECTED", "LINGXING_WMS_SIGNATURE_REJECTED", "LINGXING_WMS_SOURCE_REJECTED", "LINGXING_WMS_INVALID_RESPONSE", "LINGXING_WMS_RATE_LIMITED", "LINGXING_WMS_HTTP_RATE_LIMITED", "LINGXING_WMS_SOURCE_RATE_LIMITED", @@ -122,6 +123,13 @@ public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient { quantity(n,"wmsLength"),quantity(n,"wmsWidth"),quantity(n,"wmsHeight"),optional(n,"wmsSizeUnit"))); } + public JsonNode inventoryFlow(SelfopRequestContext context,String warehouse,String customer,String sku, + LocalDateTime start,LocalDateTime end) { + gate(context);argument(warehouse,64);argument(customer,64);argument(sku,128); + return post(context,"/inventory-flow/query",Map.of("warehouseCode",warehouse,"customerCode",customer,"sku",sku, + "startTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(start),"endTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(end))); + } + private Summary summary(String account, JsonNode n) { List items = null; JsonNode products = n.get("productList"); @@ -160,9 +168,10 @@ public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient { case "/product-inventory/query" -> Operation.PRODUCT_INVENTORY; case "/customers/query" -> Operation.CUSTOMERS; case "/products/query" -> Operation.PRODUCTS; + case "/inventory-flow/query" -> Operation.INVENTORY_FLOW; default -> throw failure("EGRESS_OPERATION_INVALID"); }; - int size = fields.containsKey("size") ? ((Number)fields.get("size")).intValue() : 0; + int size = op==Operation.INVENTORY_FLOW?1000:fields.containsKey("size") ? ((Number)fields.get("size")).intValue() : 0; String warehouse=(String)fields.get("warehouseCode"); String key=op==Operation.OUTBOUND_DETAIL ? SelfopSupport.json(List.of(fields.get("customerCode"),fields.get("deliveryNo"))) : null; var permission = egress.reserve(context,op,size,warehouse,key,SelfopSupport.hash(fields)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryFlowProbeController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryFlowProbeController.java new file mode 100644 index 000000000..8e061e694 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryFlowProbeController.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.selfoperated.analytics.controller; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.selfoperated.analytics.sync.InventoryFlowProbeService; +import org.springframework.web.bind.annotation.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +@RestController +@RequestMapping("/api/platform/v1/last-leg/self-operated-analytics/inventory-flow-probe") +public class PlatformInventoryFlowProbeController { + private final InventoryFlowProbeService service; + public PlatformInventoryFlowProbeController(InventoryFlowProbeService service){this.service=service;} + @PostMapping + @PreAuthorize("hasAuthority('platform:self-operated-analytics:sync')") + public ApiResponse probe(@AuthenticationPrincipal AuthenticatedUser user, + @RequestBody InventoryFlowProbeService.Request request) { + if(user==null||!Long.valueOf(0).equals(user.tenantId())||!"PLATFORM".equals(user.appCode())||user.impersonated() + ||!user.permissions().contains("platform:self-operated-analytics:sync"))throw new ResponseStatusException(HttpStatus.FORBIDDEN); + return ApiResponse.success(service.probe(request,user.username())); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java index b324ee8e8..2c3928d20 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java @@ -15,7 +15,7 @@ public interface SelfopCoordinationMapper { SelfopSyncTask lockTask(long id); @Select("SELECT * FROM ll_selfop_sync_run WHERE id=#{id} FOR UPDATE") SelfopSyncRun lockRun(long id); - @Select("SELECT * FROM ll_selfop_sync_task WHERE execution_purpose=#{purpose} AND status IN ('READY','RETRY_WAIT','RUNNING') AND (execution_purpose='BUSINESS' OR (status='READY' AND validation_root_task_id=id)) AND (next_retry_at_utc IS NULL OR next_retry_at_utc<=#{now}) ORDER BY CASE WHEN task_type IN ('RECHECK','REBUILD') THEN 1 WHEN task_role='WAREHOUSE_SYNC' THEN 0 WHEN task_type='BACKFILL' THEN 3 ELSE 2 END,id LIMIT #{limit}") + @Select("SELECT * FROM ll_selfop_sync_task WHERE execution_purpose=#{purpose} AND task_type<>'INVENTORY_FLOW_PROBE' AND status IN ('READY','RETRY_WAIT','RUNNING') AND (execution_purpose='BUSINESS' OR (status='READY' AND validation_root_task_id=id)) AND (next_retry_at_utc IS NULL OR next_retry_at_utc<=#{now}) ORDER BY CASE WHEN task_type IN ('RECHECK','REBUILD') THEN 1 WHEN task_role='WAREHOUSE_SYNC' THEN 0 WHEN task_type='BACKFILL' THEN 3 ELSE 2 END,id LIMIT #{limit}") List due(@Param("now") LocalDateTime now, @Param("limit") int limit, @Param("purpose") String purpose); // Started inventory work may resume across midnight; unstarted historical slots remain excluded. // Publication independently requires the planned day and observation day to match for daily samples. diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java index 34f6b706e..b932dc712 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java @@ -7,7 +7,7 @@ public final class SelfopEgressDtos { boolean continuousEnabled,String sourceAccountFingerprint,boolean grantPresent,String grantFingerprint, long intervalMs,int permitMaxSeconds,boolean executionEnabled,int pendingMaxSeconds, boolean exclusiveHandoffVerified,boolean priorEgressClosed,boolean inflightDrained,boolean transferredBudgetVerified){} - public enum Operation {WAREHOUSES,OUTBOUND_QUERY,OUTBOUND_LOCATE,OUTBOUND_DETAIL,PRODUCT_INVENTORY,CUSTOMERS,PRODUCTS} + public enum Operation {WAREHOUSES,OUTBOUND_QUERY,OUTBOUND_LOCATE,OUTBOUND_DETAIL,PRODUCT_INVENTORY,CUSTOMERS,PRODUCTS,INVENTORY_FLOW} 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) public record AuthorizationDenial(String errorCode,boolean sampleDone,Instant waitUntil){} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java index 144d519fc..e8af72117 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java @@ -39,6 +39,11 @@ public class SelfopEgressService { 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);}} verify(task,run,root,account,now); + boolean flowOperation=operation==Operation.INVENTORY_FLOW; + if(flowOperation!="INVENTORY_FLOW_PROBE".equals(task.getTaskType()) + ||flowOperation&&(!"test".equals(config.getEnvironment())||!"BUSINESS".equals(task.getExecutionPurpose()) + ||pageSize!=1000||!config.getWarehouses().containsKey(warehouseCode) + ||business==null||business.http!=0||!Objects.equals(task.getRequestHash(),requestHash)))throw reject("EGRESS_FLOW_PROBE_INVALID"); 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"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/InventoryFlowProbeService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/InventoryFlowProbeService.java new file mode 100644 index 000000000..4da795765 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/InventoryFlowProbeService.java @@ -0,0 +1,103 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.selfoperated.analytics.client.RestLingxingAnalyticsClient; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDateTime; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +/** TEST-only, one audited request. No schedule, retry, publication or historical inventory mutation. */ +@Service +public class InventoryFlowProbeService { + private final SelfopAnalyticsProperties config; + private final SelfopSyncTaskMapper tasks; + private final SelfopSyncRunMapper runs; + private final SelfopOutboundDiscoveryScopeMapper accounts; + private final SelfopLeaseService leases; + private final RestLingxingAnalyticsClient client; + private final TransactionTemplate tx; + public InventoryFlowProbeService(SelfopAnalyticsProperties config,SelfopSyncTaskMapper tasks,SelfopSyncRunMapper runs, + SelfopOutboundDiscoveryScopeMapper accounts,SelfopLeaseService leases,RestLingxingAnalyticsClient client,TransactionTemplate tx) { + this.config=config;this.tasks=tasks;this.runs=runs;this.accounts=accounts;this.leases=leases;this.client=client;this.tx=tx; + } + public record Request(String requestKey,String warehouseCode,String customerCode,String sku,LocalDateTime startTime,LocalDateTime endTime) { + public Map providerRequest(){return Map.of("warehouseCode",warehouseCode,"customerCode",customerCode,"sku",sku, + "startTime",java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(startTime), + "endTime",java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(endTime));} + } + public record Result(String taskNo,boolean complete,JsonNode page) {} + static void validate(Request q,LocalDateTime now) { + require(q!=null,"SELFOP_FLOW_REQUEST_REQUIRED");identity(q.requestKey(),80);identity(q.warehouseCode(),64); + identity(q.customerCode(),64);identity(q.sku(),128); + require(q.startTime()!=null&&q.endTime()!=null&&q.startTime().isBefore(q.endTime()) + &&!q.endTime().isAfter(q.startTime().plusDays(31))&&!q.endTime().isAfter(now),"SELFOP_FLOW_WINDOW_INVALID"); + } + public Result probe(Request q,String actor) { + validate(q,utcNow().plusHours(8)); + require("test".equals(config.getEnvironment())&&"BUSINESS".equals(config.getCollectionMode()) + &&config.isExecutionEnabled()&&config.getWarehouses().containsKey(q.warehouseCode()),"SELFOP_FLOW_PROBE_DISABLED"); + String key="flow-probe:"+q.requestKey(),digest=hash(q.providerRequest()); + var account=accounts.selectOne(new QueryWrapper().eq("source_account",config.getSourceAccount())); + require(account!=null,"SELFOP_SCOPE_MISSING"); + var lease=leases.acquireDiscovery(account.getId(),"flow-probe-"+UUID.randomUUID()); + try { + var existing=tasks.selectOne(new QueryWrapper().eq("source_account",config.getSourceAccount()).eq("request_key",key)); + if(existing!=null) { + require(digest.equals(existing.getRequestHash()),"SELFOP_IDEMPOTENCY_CONFLICT"); + require("SAMPLE_DONE".equals(existing.getStatus())&&existing.getRoutingManifestJson()!=null,"SELFOP_FLOW_PREVIOUS_ATTEMPT_REQUIRES_REVIEW"); + return read(existing.getRoutingManifestJson(),Result.class); + } + var now=utcNow();var task=new SelfopSyncTask();var run=new SelfopSyncRun(); + task.setId(IdWorker.getId());task.setTaskNo("LXFLOW-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT)); + task.setSourceAccount(config.getSourceAccount());task.setExecutionPurpose("BUSINESS");task.setTaskRole("ACCOUNT_DISCOVERY"); + task.setDiscoveryScopeId(account.getId());task.setTaskType("INVENTORY_FLOW_PROBE");task.setStatus("RUNNING"); + task.setApplyState("NOT_APPLICABLE");task.setAttemptCount(1);task.setRequestKey(key);task.setRequestHash(digest); + task.setScopeHash(digest);task.setQueryScopeJson(json(q.providerRequest()));task.setBusinessRootTaskId(task.getId()); + task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now));task.setRequestedBy(actor);task.setCreatedAt(now);task.setUpdatedAt(now); + run.setId(IdWorker.getId());run.setTaskId(task.getId());run.setSourceAccount(config.getSourceAccount());run.setExecutionPurpose("BUSINESS"); + run.setRunRole("ACCOUNT_DISCOVERY");run.setAttemptNo(1);run.setScopeHash(digest);run.setScopeSnapshotJson(task.getQueryScopeJson()); + run.setFenceToken(lease.fence());run.setStatus("FETCHING");run.setLastPhase("FETCHING");run.setFetchStartedAtUtc(now); + run.setCoverageStatus("UNKNOWN");run.setPageCount(0);run.setUniqueObjectCount(0L);run.setCreatedAt(now);run.setUpdatedAt(now); + tx.executeWithoutResult(s->{leases.verify(lease);tasks.insert(task);runs.insert(run);}); + try { + JsonNode page=client.inventoryFlow(SelfopRequestContext.forTask(task,run,config),q.warehouseCode(),q.customerCode(),q.sku(),q.startTime(),q.endTime()); + boolean complete=validatePage(page); + var result=new Result(task.getTaskNo(),complete,page); + tx.executeWithoutResult(s->{ + leases.verify(lease);var done=utcNow(); + task.setStatus("SAMPLE_DONE");task.setRoutingManifestJson(json(result));task.setUpdatedAt(done); + run.setStatus("SAMPLE_DONE");run.setLastPhase("INVENTORY_FLOW_PROBE");run.setPageCount(1); + run.setSourceTotalCount(page.path("total").asLong());run.setUniqueObjectCount((long)page.path("records").size()); + run.setCoverageStatus(complete?"COMPLETE":"PARTIAL");run.setFetchFinishedAtUtc(done);run.setFinishedAtUtc(done);run.setUpdatedAt(done); + tasks.updateById(task);runs.updateById(run); + }); + return result; + } catch(RuntimeException failure) { + String error=failure instanceof com.baiyue.lastleg.selfoperated.analytics.client.AnalyticsClientException c?c.code(): + failure instanceof com.baiyue.lastleg.selfoperated.analytics.request.EgressRejectedException e?e.code():"FLOW_PROBE_FAILED"; + tx.executeWithoutResult(s->{leases.verify(lease);task.setStatus("DEAD");task.setUpdatedAt(utcNow()); + run.setStatus("FAILED");run.setErrorCode(error);run.setErrorMessage("库存流水诊断失败;不自动重试");run.setFinishedAtUtc(utcNow());run.setUpdatedAt(utcNow()); + tasks.updateById(task);runs.updateById(run);}); + throw failure; + } + } finally {leases.release(lease);} + } + static boolean validatePage(JsonNode page) { + require(page!=null&&page.path("records").isArray()&&page.path("current").asInt()==1&&page.path("size").asInt()==1000 + &&page.path("total").asLong(-1)>=0,"SELFOP_FLOW_PAGE_INVALID"); + long total=page.path("total").asLong(); + require(page.path("pages").asLong(-1)==(total==0?0:(total-1)/1000+1) + &&page.path("records").size()==Math.min(1000,total),"SELFOP_FLOW_PAGE_INVALID"); + var ids=new HashSet(); + for(var row:page.path("records"))require(row.path("id").isTextual()&&!row.path("id").asText().isBlank() + &&ids.add(row.path("id").asText()),"SELFOP_FLOW_DUPLICATE_ID"); + return total<=1000; + } +} diff --git a/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml index 740ce2f1a..46a2987fb 100644 --- a/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml @@ -21,6 +21,34 @@ info: 重试沿既有POST sync-tasks,action=RETRY/requestKey/warehouseCode/taskNo/reason,使用独立sync权限并最终复核资格。 security: [{bearerAuth: []}] paths: + /api/platform/v1/last-leg/self-operated-analytics/inventory-flow-probe: + post: + summary: TEST单SKU库存流水有界诊断 + description: >- + 仅TEST、BUSINESS、执行开启及配置仓库;PLATFORM、tenantId=0、非代登录、独立platform:self-operated-analytics:sync权限。 + 每个requestKey最多一次供应商请求、第一页最多1000条、时间范围最多31天且不得未来。 + 复用现有账号lease/fence、1200ms间隔、permit和消费记录。占用时拒绝,由操作者另行发起,不自动重试。 + 同键成功重放读取保存结果;同键异参拒绝;失败或结果未知不得重发。任务类型INVENTORY_FLOW_PROBE,成功SAMPLE_DONE,失败DEAD。 + complete仅证明本次分页记录已全部返回,不证明历史流水无缺失或库存可还原。只保存诊断结果,不发布销量、库存或日样本。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [requestKey, warehouseCode, customerCode, sku, startTime, endTime] + properties: + requestKey: {type: string, minLength: 1, maxLength: 80} + warehouseCode: {type: string, minLength: 1, maxLength: 64} + customerCode: {type: string, minLength: 1, maxLength: 64} + sku: {type: string, minLength: 1, maxLength: 128} + startTime: {type: string, description: 北京时间本地日期时间} + endTime: {type: string, description: 北京时间本地日期时间} + responses: + '200': + description: ApiResponse;成功data包含taskNo、complete与page(records、total、current、size、pages、observedAt、egress)。流水字段定义见Integration库存流水合同。业务失败保留错误码,不返回伪成功。 + '403': + description: 非平台或缺少采集权限 /api/platform/v1/last-leg/self-operated-analytics/owner-directory: get: summary: 完整资料批次中的客户名称与编码 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryFlowProbeControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryFlowProbeControllerTest.java new file mode 100644 index 000000000..0aab0ce34 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryFlowProbeControllerTest.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.selfoperated.analytics.controller; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.selfoperated.analytics.sync.InventoryFlowProbeService; +import org.junit.jupiter.api.Test; +import java.util.Set; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.security.access.prepost.PreAuthorize; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +class PlatformInventoryFlowProbeControllerTest { + @Test void probeRequiresRealPlatformSyncPermission() throws Exception { + var service=mock(InventoryFlowProbeService.class);var c=new PlatformInventoryFlowProbeController(service); + var reader=new AuthenticatedUser(1L,0L,0L,1L,"reader","PLATFORM","WEB",Set.of("platform:self-operated-analytics:list")); + var customer=new AuthenticatedUser(1L,1L,1L,1L,"customer","CLIENT","WEB",Set.of("platform:self-operated-analytics:sync")); + assertThrows(ResponseStatusException.class,()->c.probe(null,null));assertThrows(ResponseStatusException.class,()->c.probe(reader,null)); + assertThrows(ResponseStatusException.class,()->c.probe(customer,null));verifyNoInteractions(service); + var writer=new AuthenticatedUser(1L,0L,0L,1L,"operator","PLATFORM","WEB",Set.of("platform:self-operated-analytics:sync")); + c.probe(writer,null);verify(service).probe(null,"operator"); + assertEquals("hasAuthority('platform:self-operated-analytics:sync')",PlatformInventoryFlowProbeController.class + .getMethod("probe",AuthenticatedUser.class,InventoryFlowProbeService.Request.class).getAnnotation(PreAuthorize.class).value()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java index 1468103ec..50856827c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java @@ -44,6 +44,21 @@ class SelfopEgressServiceTest { success(c,Operation.CUSTOMERS);success(reserve(Operation.PRODUCTS,100,null,null),Operation.PRODUCTS); assertThat(budget().confirmedHttp).isEqualTo(2); } + @Test void flowProbeIsTestOnlyBoundToFrozenHashAndExactlyOneAttempt(){ + testBusiness(5,300);task.setTaskType("INVENTORY_FLOW_PROBE");task.setRequestHash(INTERNAL); + assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,100,null,null)).hasMessage("EGRESS_FLOW_PROBE_INVALID"); + assertThatThrownBy(()->reserve(Operation.INVENTORY_FLOW,1000,"OTHER",null)).hasMessage("EGRESS_FLOW_PROBE_INVALID"); + assertThatThrownBy(()->service.reserve(context(),Operation.INVENTORY_FLOW,1000,"A",null,PROVIDER)).hasMessage("EGRESS_FLOW_PROBE_INVALID"); + var c=reserve(Operation.INVENTORY_FLOW,1000,"A",null);success(c,Operation.INVENTORY_FLOW); + assertThatThrownBy(()->reserve(Operation.INVENTORY_FLOW,1000,"A",null)).hasMessage("EGRESS_FLOW_PROBE_INVALID"); + assertThat(businessBudget().http).isEqualTo(1);assertThat(budget().confirmedHttp).isEqualTo(1); + } + @Test void flowPermissionFailureDoesNotPauseExistingSalesAccount(){ + testBusiness(5,300);task.setTaskType("INVENTORY_FLOW_PROBE");task.setRequestHash(INTERNAL); + var c=reserve(Operation.INVENTORY_FLOW,1000,"A",null);service.authorize(new AuthorizationRequest(c,Operation.INVENTORY_FLOW,PROVIDER)); + service.complete(new CompletionRequest(c,Operation.INVENTORY_FLOW,PROVIDER,1,"LINGXING_WMS_FLOW_PERMISSION_DENIED",200,11008,false,null)); + assertThat(account.getApiPauseReason()).isNull();assertThat(budget().confirmedHttp).isEqualTo(1); + } @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"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/InventoryFlowProbeServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/InventoryFlowProbeServiceTest.java new file mode 100644 index 000000000..8b79f2b90 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/InventoryFlowProbeServiceTest.java @@ -0,0 +1,55 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; +import org.junit.jupiter.api.Test; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.client.RestLingxingAnalyticsClient; +import org.springframework.transaction.support.TransactionTemplate; +class InventoryFlowProbeServiceTest { + @Test void successfulProbeIsAuditedReplayedAndNeverPublished() throws Exception { + var cfg=new SelfopAnalyticsProperties();cfg.setEnvironment("test");cfg.setCollectionMode("BUSINESS");cfg.setSourceAccount("demo"); + cfg.getWarehouses().put("W",new SelfopAnalyticsProperties.Warehouse()); + var tasks=mock(SelfopSyncTaskMapper.class);var runs=mock(SelfopSyncRunMapper.class);var accounts=mock(SelfopOutboundDiscoveryScopeMapper.class); + var leases=mock(SelfopLeaseService.class);var client=mock(RestLingxingAnalyticsClient.class);var tx=mock(TransactionTemplate.class); + doAnswer(i->{((java.util.function.Consumer)i.getArgument(0)).accept(null);return null;}).when(tx).executeWithoutResult(any()); + var a=new SelfopOutboundDiscoveryScope();a.setId(1L);when(accounts.selectOne(any())).thenReturn(a); + var lease=new SelfopLeaseService.Lease(1L,"probe",2L,true);when(leases.acquireDiscovery(eq(1L),anyString())).thenReturn(lease); + var saved=new java.util.concurrent.atomic.AtomicReference(); + when(tasks.selectOne(any())).thenAnswer(i->saved.get());when(tasks.insert(any(SelfopSyncTask.class))).thenAnswer(i->{saved.set(i.getArgument(0));return 1;}); + var page=new ObjectMapper().readTree("{\"current\":1,\"size\":1000,\"pages\":1,\"total\":1,\"records\":[{\"id\":\"1\"}]}"); + when(client.inventoryFlow(any(),anyString(),anyString(),anyString(),any(),any())).thenReturn(page); + var s=new InventoryFlowProbeService(cfg,tasks,runs,accounts,leases,client,tx); + var end=LocalDateTime.of(2026,1,1,0,0);var q=new InventoryFlowProbeService.Request("one","W","C","S",end.minusDays(30),end); + var first=s.probe(q,"operator");assertTrue(first.complete());assertEquals(first,s.probe(q,"operator")); + assertEquals("SAMPLE_DONE",saved.get().getStatus());assertNull(saved.get().getResultPublishedRunId()); + var capture=org.mockito.ArgumentCaptor.forClass(SelfopSyncRun.class);verify(runs).insert(capture.capture()); + assertEquals("SAMPLE_DONE",capture.getValue().getStatus());assertNull(capture.getValue().getPublishedAtUtc());assertNull(capture.getValue().getInventoryDailySampleDate()); + verify(client,times(1)).inventoryFlow(any(),anyString(),anyString(),anyString(),any(),any());verify(leases,times(2)).release(lease); + assertThrows(IllegalStateException.class,()->s.probe(new InventoryFlowProbeService.Request("one","W","C","OTHER",q.startTime(),q.endTime()),"operator")); + verify(client,times(1)).inventoryFlow(any(),anyString(),anyString(),anyString(),any(),any()); + saved.set(null);when(client.inventoryFlow(any(),anyString(),anyString(),anyString(),any(),any())).thenThrow(new IllegalStateException("PROVIDER_FAILURE")); + assertThrows(IllegalStateException.class,()->s.probe(q,"operator"));assertEquals("DEAD",saved.get().getStatus()); + assertThrows(IllegalStateException.class,()->s.probe(q,"operator")); + verify(client,times(2)).inventoryFlow(any(),anyString(),anyString(),anyString(),any(),any()); + } + @Test void refusesFutureAndOversizedWindow() { + var now=LocalDateTime.of(2026,10,15,12,0); + InventoryFlowProbeService.validate(new InventoryFlowProbeService.Request("one","W","C","S",now.minusDays(30),now),now); + assertThrows(IllegalStateException.class,()->InventoryFlowProbeService.validate(new InventoryFlowProbeService.Request("one","W","C","S",now.minusDays(32),now),now)); + assertThrows(IllegalStateException.class,()->InventoryFlowProbeService.validate(new InventoryFlowProbeService.Request("one","W","C","S",now,now.plusSeconds(1)),now)); + } + @Test void refusesPartialPageAndDuplicateEvidence() throws Exception { + var mapper=new ObjectMapper(); + assertTrue(InventoryFlowProbeService.validatePage(mapper.readTree("{\"current\":1,\"size\":1000,\"pages\":1,\"total\":1,\"records\":[{\"id\":\"1\"}]}"))); + assertThrows(IllegalStateException.class,()->InventoryFlowProbeService.validatePage(mapper.readTree("{\"current\":1,\"size\":1000,\"pages\":1,\"total\":2,\"records\":[{\"id\":\"1\"}]}"))); + assertThrows(IllegalStateException.class,()->InventoryFlowProbeService.validatePage(mapper.readTree("{\"current\":1,\"size\":1000,\"pages\":1,\"total\":2,\"records\":[{\"id\":\"1\"},{\"id\":\"1\"}]}"))); + var root=mapper.createObjectNode().put("current",1).put("size",1000).put("pages",2).put("total",1001); + var rows=root.putArray("records");for(int i=0;i<1000;i++)rows.addObject().put("id",String.valueOf(i)); + assertFalse(InventoryFlowProbeService.validatePage(root)); + } +} + From 99215bbb796248d4e3f6ad7897982678b363c072 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 15 Oct 2026 13:30:37 +0800 Subject: [PATCH 4/7] feat(selfop): reconstruct historical inventory and publish qualified turnover --- deploy/sql/README.md | 4 +- deploy/sql/海外区域统一增量-MySQL8.sql | 87 +++++++- .../LingxingInternalController.java | 9 + .../dto/lingxing/LingxingWmsDtos.java | 5 +- .../provider/lingxing/LingxingWmsClient.java | 36 +++- .../resources/openapi/lingxing-wms-v1.yaml | 33 +++- .../lingxing/LingxingWmsClientTest.java | 52 ++++- .../client/RestLingxingAnalyticsClient.java | 10 +- .../history/InventoryFlowCollector.java | 121 ++++++++++++ .../analytics/history/InventoryFlowFacts.java | 91 +++++++++ .../analytics/history/InventoryFlowSlice.java | 54 +++++ .../history/InventoryHistoryBuilder.java | 186 ++++++++++++++++++ .../history/InventoryHistoryCalculator.java | 141 +++++++++++++ .../history/InventoryHistoryVersions.java | 100 ++++++++++ .../history/InventoryHistoryWorker.java | 56 ++++++ .../mapper/SelfopCoordinationMapper.java | 4 +- .../model/SelfopAnalyticsProperties.java | 3 + .../model/SelfopCollectionPolicy.java | 11 ++ .../query/InventoryTurnoverContext.java | 48 +++-- .../query/InventoryTurnoverPolicy.java | 7 +- .../query/InventoryTurnoverReadMapper.java | 27 +-- .../query/InventoryTurnoverResponses.java | 2 +- .../analytics/query/InventoryTurnoverSql.java | 40 ++-- .../request/SelfopEgressService.java | 14 +- .../retention/SalesRetentionMapper.java | 6 +- .../src/main/resources/db/lastleg.sql | 85 +++++++- .../resources/db/selfop-inventory-history.sql | 82 ++++++++ .../self-operated-inventory-turnover-v1.yaml | 16 +- .../history/InventoryFlowSliceTest.java | 42 ++++ .../InventoryHistoryCalculatorTest.java | 85 ++++++++ .../history/InventoryHistoryPipelineTest.java | 126 ++++++++++++ .../history/InventoryHistoryVersionsTest.java | 87 ++++++++ .../query/InventoryTurnoverContextTest.java | 32 ++- .../query/InventoryTurnoverSqlTest.java | 37 ++-- .../request/SelfopEgressServiceTest.java | 19 ++ 35 files changed, 1652 insertions(+), 106 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryFlowCollector.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryFlowFacts.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryFlowSlice.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryBuilder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryCalculator.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryVersions.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryWorker.java create mode 100644 lastleg-service/src/main/resources/db/selfop-inventory-history.sql create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryFlowSliceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryCalculatorTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryPipelineTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryVersionsTest.java diff --git a/deploy/sql/README.md b/deploy/sql/README.md index c2464d426..e2293ff25 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -30,6 +30,8 @@ 客户/商品资料基线为 `lastleg-service/src/main/resources/db/selfop-master-data.sql`,仅新增 `ll_selfop_owner`、`ll_selfop_product` 两表,并同步在 Lastleg 和海外统一空库基线。按来源账号、货主编码、SKU 和完整资料版本关联,不创建平台客户、收费对象或独立映射表;未知实测尺寸保持 NULL。对 TEST 已有库只审阅并授权这两张新表的 CREATE,不能重放统一初始化文件,也不重置采集数据。 +历史库存还原基线为 `lastleg-service/src/main/resources/db/selfop-inventory-history.sql`,新增流水事实、历史日版本和日库存三表;同步 Lastleg 与海外空库基线。仅执行这三表 CREATE,不重放原始库存或销量表。匹配版本复用 `sync_task/run/stage` 的 `INVENTORY_FLOW_HISTORY`、`INVENTORY_HISTORY_BUILD` 与 `INVENTORY_FLOW/HISTORY_INPUT`,原库存快照不改写。完成三表并部署 Integration、Lastleg、Admin 后,才启用 `baiyue.self-operated-analytics.inventory-history-enabled=true`;默认关闭,历史目标30日优先再60日。保留当前账号出口、1200ms间隔和所有被历史版本引用的证据;现有销量副本淘汰不得删除这些引用。 + ## 唯一入口与区域边界 两个统一 SQL 文件仍只用于空库初始化,名称中的“增量”不表示可向已有业务库重放。已有生产库的受控结构补齐单独放在 `migrations/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。 @@ -37,7 +39,7 @@ | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 199 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 202 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index f97c1dbad..6b745c529 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -4002,7 +4002,7 @@ CREATE TABLE ll_selfop_sync_task ( observation_baseline BIGINT NULL COMMENT '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测', request_key VARCHAR(128) NOT NULL COMMENT '共享发现包含切片/计划槽位;分仓任务包含仓库、任务族及切片或计划槽位;人工附请求幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求类型、查询范围、范围摘要的指纹;同键不同请求拒绝', - task_type VARCHAR(24) NOT NULL COMMENT 'SCHEDULED、MANUAL_RETRY、BACKFILL、RECHECK、REBUILD、VALIDATION;验证不由自动计划创建', + task_type VARCHAR(24) NOT NULL COMMENT '计划、补采、复核、重建与验证;INVENTORY_FLOW_HISTORY为流水片,INVENTORY_HISTORY_BUILD为本地历史还原', planned_at_utc DATETIME(3) NULL COMMENT '计划时点;人工请求可空', query_from_utc DATETIME(3) NULL COMMENT '出库查询区间UTC下界,库存不填', query_until_utc DATETIME(3) NULL COMMENT '出库查询区间UTC上界,不含端点,库存不填', @@ -4356,3 +4356,88 @@ CREATE TABLE ll_selfop_product ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星商品资料版本'; SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 199 AS initialized_tables; + +USE baiyue_lastleg; + +-- 历史库存基线;执行前必须取得指定环境的建表授权。 +CREATE TABLE IF NOT EXISTS ll_selfop_inventory_flow ( + id BIGINT NOT NULL COMMENT '应用生成的流水记录标识', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码', + customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码', + sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码', + source_flow_id VARCHAR(64) NOT NULL COMMENT '来源流水字符串标识', + product_quality INT NOT NULL COMMENT '来源品质,零正品一次品,其他未知', + cell_no VARCHAR(128) NULL COMMENT '来源库位编码', + inner_batch_no VARCHAR(128) NULL COMMENT '来源内部批次号', + bill_no VARCHAR(128) NULL COMMENT '来源业务单号', + source_no VARCHAR(128) NULL COMMENT '来源关联单号', + bill_type VARCHAR(32) NULL COMMENT '来源业务类型,未验证类型不得参与正式还原', + book_qty DECIMAL(20,6) NULL COMMENT '来源记账增减量,未知为空', + book_change_qty_raw VARCHAR(64) NULL COMMENT '来源带符号变动量原文', + delta_quantity DECIMAL(20,6) NULL COMMENT '解析后的有符号实物变动量', + close_quantity DECIMAL(20,6) NULL COMMENT '对应库位批次品质链的结存', + source_create_time VARCHAR(64) NULL COMMENT '来源北京时间原文', + event_at_utc DATETIME(3) NULL COMMENT '流水发生UTC时刻,无法解析时为空', + business_date DATE NULL COMMENT '北京时间业务日期', + quantity_unit VARCHAR(16) NULL COMMENT '已确认数量单位,未知为空', + content_hash CHAR(64) NOT NULL COMMENT '当前事实内容摘要', + revision_no BIGINT NOT NULL COMMENT '同来源流水单调增加的修订号', + stage_id BIGINT NOT NULL COMMENT '当前事实对应不可变暂存证据', + first_run_id BIGINT NOT NULL COMMENT '首次观察执行尝试', + last_run_id BIGINT NOT NULL COMMENT '最近确认执行尝试', + last_observed_at DATETIME(3) NOT NULL COMMENT '最近观察UTC时刻', + validation_status VARCHAR(16) NOT NULL COMMENT '字段资格,有效或未知', + validation_reason VARCHAR(64) NULL COMMENT '数量或身份待核验原因', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_flow_source (source_account,warehouse_code,source_flow_id), + KEY idx_selfop_flow_sku (source_account,warehouse_code,customer_code,sku_code,event_at_utc), + KEY idx_selfop_flow_day (source_account,warehouse_code,business_date), + CONSTRAINT chk_selfop_flow_revision CHECK (revision_no > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='自营历史库存流水最新事实,旧版本保留于暂存证据'; + +CREATE TABLE IF NOT EXISTS ll_selfop_inventory_day_version ( + id BIGINT NOT NULL COMMENT '不可变历史日版本标识', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码', + business_date DATE NOT NULL COMMENT '北京时间日末所属日期', + scope_hash CHAR(64) NOT NULL COMMENT '业务范围摘要', + rule_version VARCHAR(32) NOT NULL COMMENT '日末库存还原规则版本', + build_run_id BIGINT NOT NULL COMMENT '历史重建执行尝试', + publication_group_id BIGINT NOT NULL COMMENT '必须一起切换的发布组标识', + sales_run_id BIGINT NULL COMMENT '构建时固定的销量证据版本', + anchor_run_id BIGINT NOT NULL COMMENT '固定的独立库存锚点版本', + input_hash CHAR(64) NOT NULL COMMENT '冻结流水修订与覆盖及锚点输入摘要', + evidence_json JSON NOT NULL COMMENT '固定输入清单及不可变证据引用', + status VARCHAR(16) NOT NULL COMMENT '构建中、就绪、已发布、已替代或失败', + head_slot TINYINT NULL COMMENT '当前日头为一,非当前版本为空', + valid_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '合格商品数', + unknown_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '待核验商品数', + created_at DATETIME(3) NOT NULL COMMENT '版本创建UTC时刻', + published_at DATETIME(3) NULL COMMENT '发布组提交UTC时刻', + superseded_at DATETIME(3) NULL COMMENT '被新组替代的UTC时刻', + invalidated_at DATETIME(3) NULL COMMENT '已确认输入失效的UTC时刻', + invalidation_reason VARCHAR(64) NULL COMMENT '旧版本失效原因', + rebuild_run_id BIGINT NULL COMMENT '处理失效版本的重建尝试', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_history_head (source_account,warehouse_code,business_date,head_slot), + UNIQUE KEY uk_selfop_history_build_day (build_run_id,business_date), + KEY idx_selfop_history_group (publication_group_id,status), + CONSTRAINT chk_selfop_history_head CHECK (head_slot IS NULL OR (head_slot=1 AND status='PUBLISHED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日库存不可变版本及原子发布日头'; + +CREATE TABLE IF NOT EXISTS ll_selfop_inventory_daily ( + day_version_id BIGINT NOT NULL COMMENT '所属历史日版本', + customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码', + sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码', + product_quality INT NOT NULL COMMENT '来源库存品质', + physical_quantity DECIMAL(20,6) NULL COMMENT '日末实物库存,未知为空,已确认零为零', + quality_status VARCHAR(16) NOT NULL COMMENT '日末资格,有效或未知', + quality_reason VARCHAR(64) NULL COMMENT '缺覆盖或锚点等未知原因', + anchor_reference BIGINT NULL COMMENT '独立库存锚点版本', + evidence_hash CHAR(64) NOT NULL COMMENT '该日计算证据摘要', + source VARCHAR(32) NOT NULL COMMENT '库存来源标记,流水还原', + PRIMARY KEY (day_version_id,customer_code,sku_code,product_quality), + KEY idx_selfop_daily_identity (customer_code,sku_code,day_version_id), + CONSTRAINT chk_selfop_daily_quantity CHECK ((quality_status='VALID' AND physical_quantity IS NOT NULL AND physical_quantity>=0) OR (quality_status='UNKNOWN' AND physical_quantity IS NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日末分货主商品品质实物库存'; diff --git a/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java b/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java index 166a1c26b..25ca5f81d 100644 --- a/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java +++ b/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java @@ -87,6 +87,15 @@ public class LingxingInternalController { tokenGuard.verify(token);return ApiResponse.success(client.queryInventoryFlow(request,context(context))); } + @Operation(summary = "读取固定单仓最多一天的全部货主品质库存流水单页") + @PostMapping("/inventory-flow-page/query") + public ApiResponse> inventoryFlowPage( + @RequestHeader(value="X-Internal-Service-Token",required=false) String token, + @RequestHeader(value="X-Lingxing-Egress-Context",required=false) String context, + @Valid @RequestBody InventoryFlowPageRequest request) { + tokenGuard.verify(token);return ApiResponse.success(client.queryInventoryFlowPage(request,context(context))); + } + @Operation(summary = "读取单仓全部货主、全部品质且保留零库存的产品库存单页") @PostMapping("/product-inventory/query") public ApiResponse> inventory( diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java index 379c617eb..19cc34506 100644 --- a/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java +++ b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java @@ -23,9 +23,12 @@ public final class LingxingWmsDtos { public record InventoryFlowRequest(@NotBlank @Size(max=64) String warehouseCode, @NotBlank @Size(max=64) String customerCode, @NotBlank @Size(max=128) String sku, @NotNull LocalDateTime startTime, @NotNull LocalDateTime endTime) { } + public record InventoryFlowPageRequest(@NotBlank @Size(max=64) String warehouseCode, + @NotNull LocalDateTime startTime,@NotNull LocalDateTime endTime, + @Min(1) int current,@Min(1) @Max(1000) int size) { } public record InventoryFlow(String id, String customerCode, String whCode, String productSku, String cellNo, String innerBatchNo, String billNo, String billType, Integer productQuality, - BigDecimal bookQty, String bookChangQty, BigDecimal closeQty, String createTime) { } + BigDecimal bookQty, String bookChangQty, BigDecimal closeQty, String createTime, String sourceNo) { } public record Customer(String customerCode, String customerName, String status) { } public record Product(String customerCode, String sku, String productName, BigDecimal length, BigDecimal width, BigDecimal height, String sizeUnit, diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java index b74d26299..e04c31ed7 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java @@ -173,16 +173,35 @@ public final class LingxingWmsClient { body.put("barcodeAccurate",request.sku());body.put("startTime",SOURCE_TIME.format(request.startTime())); body.put("endTime",SOURCE_TIME.format(request.endTime()));body.put("sortName","createTime");body.put("sortOrder","asc"); return execute("/openapi/v2/stock/stockCellBook/page",body,context,Operation.INVENTORY_FLOW, - (data,metadata)->mappedPage(data,1,1000,"current","size",false,metadata,row->{ + (data,metadata)->mappedPage(data,1,1000,"current","size",true,metadata,row->{ if(!request.warehouseCode().equals(requiredText(row,"whCode",64)) ||!request.customerCode().equals(requiredText(row,"customerCode",64)) ||!request.sku().equals(requiredText(row,"productSku",128)))throw invalidResponse("FLOW_IDENTITY_MISMATCH",200); - JsonNode id=row.get("id"); - if(id==null||!id.isIntegralNumber())throw invalidResponse("FLOW_ID_MISSING",200); - return new InventoryFlow(id.asText(),request.customerCode(),request.warehouseCode(),request.sku(), + String id=requiredText(row,"id",64); + return new InventoryFlow(id,request.customerCode(),request.warehouseCode(),request.sku(), optionalText(row,"cellNo"),optionalText(row,"innerBatchNo"),optionalText(row,"billNo"),optionalText(row,"billType"), requiredInt(row.get("productQuality"),"PRODUCT_QUALITY"),quantity(row.get("bookQty")), - optionalText(row,"bookChangQty"),quantity(row.get("closeQty")),optionalText(row,"createTime")); + optionalText(row,"bookChangQty"),quantity(row.get("closeQty")),optionalText(row,"createTime"),optionalText(row,"sourceNo")); + })); + } + + /** One fixed warehouse interval page. Scheduling and coverage belong to Lastleg. */ + public QueryPage queryInventoryFlowPage(InventoryFlowPageRequest request,EgressContext context) { + if(request==null) throw badRequest(); + requireInput(request.warehouseCode(),64);validatePagination(request.current(),request.size(),1000); + if(request.startTime()==null || request.endTime()==null || !request.startTime().isBefore(request.endTime()) + || request.endTime().isAfter(request.startTime().plusDays(1))) throw badRequest(); + Map body=pagination(request.current(),request.size()); + body.put("whCode",request.warehouseCode());body.put("startTime",SOURCE_TIME.format(request.startTime())); + body.put("endTime",SOURCE_TIME.format(request.endTime()));body.put("sortName","createTime");body.put("sortOrder","asc"); + return execute("/openapi/v2/stock/stockCellBook/page",body,context,Operation.INVENTORY_FLOW, + (data,metadata)->mappedPage(data,request.current(),request.size(),"current","size",true,metadata,row->{ + String warehouse=requiredText(row,"whCode",64); + if(!request.warehouseCode().equals(warehouse)) throw invalidResponse("FLOW_IDENTITY_MISMATCH",200); + return new InventoryFlow(requiredText(row,"id",64),requiredText(row,"customerCode",64),warehouse, + requiredText(row,"productSku",128),optionalText(row,"cellNo"),optionalText(row,"innerBatchNo"), + optionalText(row,"billNo"),optionalText(row,"billType"),requiredInt(row.get("productQuality"),"PRODUCT_QUALITY"), + quantity(row.get("bookQty")),optionalText(row,"bookChangQty"),quantity(row.get("closeQty")),optionalText(row,"createTime"),optionalText(row,"sourceNo")); })); } @@ -343,7 +362,7 @@ public final class LingxingWmsClient { } private PageResult orders(JsonNode data, int current, int size, String exactDeliveryNo, AttemptMetadata metadata) { - QueryPage page = mappedPage(data, current, size, "current", "size", true, metadata, record -> { + QueryPage page = mappedPage(data, current, size, "current", "size", size == 100, metadata, record -> { String deliveryNo = requiredText(record, "deliveryNo", 128); if (exactDeliveryNo != null && !exactDeliveryNo.equals(deliveryNo)) throw invalidResponse("LOCATE_DELIVERY_NO_MISMATCH", 200); int status = requiredInt(record.get("status"), "STATUS"); @@ -367,7 +386,7 @@ public final class LingxingWmsClient { } private QueryPage mappedPage(JsonNode data, int current, int size, String currentField, - String sizeField, boolean outbound, AttemptMetadata metadata, Function projection) { + String sizeField, boolean allowEmptyDefaultSize, AttemptMetadata metadata, Function projection) { JsonNode records = data.get("records"); if (records == null || !records.isArray()) throw invalidResponse("RECORDS_TYPE_" + nodeType(records), 200); if (records.size() > size) throw invalidResponse("RECORDS_OVER_PAGE_SIZE", 200); @@ -377,7 +396,8 @@ public final class LingxingWmsClient { int sourceSize = requiredInt(data.get(sizeField), "SIZE"); int pages = requiredInt(data.get("pages"), "PAGES"); if (sourceCurrent != current) throw invalidResponse("CURRENT_MISMATCH", 200); - boolean emptyException = outbound && current == 1 && size == 100 && sourceCurrent == 1 + // Verified for outbound size 100 and the bounded inventory-flow query only. + boolean emptyException = allowEmptyDefaultSize && current == 1 && sourceCurrent == 1 && sourceSize == 10 && total == 0 && pages == 0 && records.isEmpty(); if (sourceSize != size && !emptyException) throw invalidResponse("SIZE_MISMATCH", 200); long expectedPages = total / size + (total % size == 0 ? 0 : 1); diff --git a/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml b/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml index 99281d72c..a54b25396 100644 --- a/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml +++ b/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml @@ -51,6 +51,34 @@ x-lastleg-egress-coordination: security: - InternalServiceToken: [] paths: + /internal/integrations/lingxing/wms/inventory-flow-page/query: + post: + summary: One frozen warehouse inventory-flow page + description: >- + Shared INVENTORY_FLOW permit, one upstream request, no automatic retry. Fixed warehouse and + inclusive Beijing start/end bounds at most one day, current>=1, size 1..1000, sorted by createTime asc. + Customer/SKU filters are absent. Lastleg validates persisted task/page binding, page counts, + duplicate IDs and ordering before admitting coverage. Only quantity/identity whitelist fields return. + Empty first-page current=1,size=10,total=0,pages=0,records=[] is normalized to the requested size. + parameters: + - {$ref: '#/components/parameters/LingxingEgressContext'} + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [warehouseCode, startTime, endTime, current, size] + properties: + warehouseCode: {type: string, minLength: 1, maxLength: 64} + startTime: {type: string, format: date-time, description: Beijing local datetime without offset} + endTime: {type: string, format: date-time, description: Inclusive bound, at most one day after start} + current: {type: integer, minimum: 1} + size: {type: integer, minimum: 1, maximum: 1000} + responses: + '200': + description: Envelope of QueryPage with current/size/total/pages/records and source pagination evidence, or sanitized ProviderFailure. Rows retain string id, customerCode, whCode, productSku, cellNo, innerBatchNo, billNo, sourceNo, billType, productQuality, numeric bookQty/closeQty and string bookChangQty/createTime. /internal/integrations/lingxing/wms/inventory-flow/query: post: summary: One SKU inventory-flow diagnostic page @@ -59,6 +87,9 @@ paths: at most 31 days, current=1 and size=1000 fixed. No automatic next page or retry. Source quality and signed quantities are preserved, not translated to inventory-snapshot quality. Source 11008 only rejects this optional operation; authentication, rate-limit and uncertain-result account protection remain effective. + Source movement IDs must be strings. An empty first response with current=1, size=10, total=0, + pages=0 and records=[] is normalized to requested size=1000 while retaining sourceSize=10. + Other pagination mismatches remain errors. parameters: - {$ref: '#/components/parameters/LingxingEgressContext'} requestBody: @@ -76,7 +107,7 @@ paths: endTime: {type: string, format: date-time, description: Beijing local datetime without offset} responses: '200': - description: Standard envelope with QueryPage or sanitized ProviderFailure. Flow rows retain id as string, customerCode, whCode, productSku, cellNo, innerBatchNo, billNo, billType, productQuality, numeric bookQty/closeQty, string bookChangQty/createTime. Unknown source fields remain null; no calculated inventory is published. + description: Standard envelope with QueryPage or sanitized ProviderFailure. Flow rows retain id as string, customerCode, whCode, productSku, cellNo, innerBatchNo, billNo, sourceNo, billType, productQuality, numeric bookQty/closeQty, string bookChangQty/createTime. Unknown source fields remain null; no calculated inventory is published. /internal/integrations/lingxing/wms/customers/query: post: summary: Query one page of customers using the existing account egress permit diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java index e27c0a72c..bbd3a6718 100644 --- a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java @@ -22,6 +22,19 @@ import static org.junit.jupiter.api.Assertions.assertThrows; /** Synthetic loopback transport only; no supplier or application services. */ class LingxingWmsClientTest { + @Test void warehouseFlowPageKeepsScopeAndUsesSharedPermit() throws Exception { + response.set(page("current","size",2,1,2,2,"{\"id\":\"F2\",\"productQuality\":0,\"customerCode\":\"C\",\"whCode\":\"W\",\"productSku\":\"SKU\",\"sourceNo\":\"SOURCE\",\"bookChangQty\":\"-1\"}")); + var request=new InventoryFlowPageRequest("W",LocalDateTime.parse("2026-10-01T00:00:00"),LocalDateTime.parse("2026-10-02T00:00:00"),2,1); + var result=client().queryInventoryFlowPage(request,context()); + assertThat(result.records().get(0).sourceNo()).isEqualTo("SOURCE"); + var business=mapper.readTree(body.get()).path("data"); + assertThat(business.path("whCode").asText()).isEqualTo("W"); + assertThat(business.has("customerCode")).isFalse();assertThat(business.has("productSku")).isFalse(); + assertThat(business.path("current").asInt()).isEqualTo(2); + assertThat(completion.get().operation()).isEqualTo(Operation.INVENTORY_FLOW); + assertThrows(LingxingWmsException.class,()->client().queryInventoryFlowPage(new InventoryFlowPageRequest("W",request.startTime(),request.endTime().plusSeconds(1),1,1000),context())); + assertThat(calls.get()).isEqualTo(1); + } @Test void customerDirectoryKeepsNamesAndUsesAccountGate() { response.set(page("page","pageSize",1,1000,1,1,"{\"customerCode\":\"C\",\"customerName\":\"示例货主\",\"status\":\"0\",\"telephoneNo\":\"not-forwarded\"}")); var result=client().queryCustomers(new WarehouseQueryRequest(1,1000),context()); @@ -315,8 +328,8 @@ class LingxingWmsClientTest { LocalDateTime.of(2026,9,14,8,0),LocalDateTime.of(2026,10,14,8,0));} @Test void flowUsesExactScopeSinglePageAndPreservesSignedChangeAndLargeId() throws Exception { response.set(page("current","size",1,1000,1,1,""" - {"id":9223372036854775806,"customerCode":"C-DEMO","whCode":"W-DEMO","productSku":"SKU-DEMO", - "productQuality":1,"bookQty":-3,"bookChangQty":"-3","closeQty":17,"createTime":"2026-10-13 12:00:00"} + {"id":"9223372036854775806","customerCode":"C-DEMO","whCode":"W-DEMO","productSku":"SKU-DEMO", + "productQuality":0,"bookQty":-3,"bookChangQty":"-3","closeQty":17,"createTime":"2026-10-13 12:00:00"} """)); var result=client().queryInventoryFlow(flow(),context()); assertThat(path.get()).isEqualTo("/openapi/v2/stock/stockCellBook/page"); @@ -324,10 +337,45 @@ class LingxingWmsClientTest { assertThat(sent.path("barcodeAccurate").asText()).isEqualTo("SKU-DEMO"); assertThat(sent.path("size").asInt()).isEqualTo(1000);assertThat(sent.has("productQuality")).isFalse(); assertThat(result.records().get(0).id()).isEqualTo("9223372036854775806"); + assertThat(result.records().get(0).productQuality()).isZero(); assertThat(result.records().get(0).bookQty()).isEqualByComparingTo("-3"); assertThat(result.records().get(0).bookChangQty()).isEqualTo("-3");assertThat(calls.get()).isEqualTo(1); assertThat(completion.get().operation()).isEqualTo(Operation.INVENTORY_FLOW); } + @Test void flowRejectsNumericIdInsteadOfCoercingUnverifiedSourceTypes() { + response.set(page("current","size",1,1000,1,1,""" + {"id":123,"customerCode":"C-DEMO","whCode":"W-DEMO","productSku":"SKU-DEMO", + "productQuality":0,"bookQty":-1,"bookChangQty":"-1","closeQty":9} + """)); + contract(()->client().queryInventoryFlow(flow(),context()),"TEXT_FIELD_TYPE_NUMBER"); + } + @Test void flowAcceptsVerifiedEmptyFirstPageAndPreservesSourcePagination() { + response.set(page("current","size",1,10,0,0,"")); + var result=client().queryInventoryFlow(flow(),context()); + assertThat(result.records()).isEmpty(); + assertThat(result.total()).isZero(); + assertThat(result.current()).isEqualTo(1); + assertThat(result.size()).isEqualTo(1000); + assertThat(result.sourceSize()).isEqualTo(10); + assertThat(calls.get()).isEqualTo(1); + assertThat(completion.get().operation()).isEqualTo(Operation.INVENTORY_FLOW); + } + @Test void flowEmptyExceptionDoesNotAcceptNonemptyOrMalformedPagination() { + var c=client(); + response.set(page("current","size",1,10,1,1,"{}")); + contract(()->c.queryInventoryFlow(flow(),context()),"SIZE_MISMATCH"); + response.set(page("current","size",2,10,0,0,"")); + contract(()->c.queryInventoryFlow(flow(),context()),"CURRENT_MISMATCH"); + response.set(page("current","size",1,10,0,1,"")); + contract(()->c.queryInventoryFlow(flow(),context()),"SIZE_MISMATCH"); + response.set(page("current","size",1,20,0,0,"")); + contract(()->c.queryInventoryFlow(flow(),context()),"SIZE_MISMATCH"); + } + @Test void flowEmptyExceptionDoesNotRelaxInventoryOrOtherOutboundSizes() { + var c=client();response.set(page("current","size",1,10,0,0,"")); + contract(()->c.queryInventory(new InventoryQueryRequest("W-DEMO",1,1000),context()),"SIZE_MISMATCH"); + contract(()->c.queryPage(query(1,20),context()),"SIZE_MISMATCH"); + } @Test void flowPermissionDenialIsScopedAndDoesNotStopOrdinaryQueries() { var c=client();response.set("{\"code\":11008,\"msg\":\"private\"}"); var error=assertThrows(LingxingWmsException.class,()->c.queryInventoryFlow(flow(),context())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java index 45f780ca7..d9ef8cd13 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java @@ -130,6 +130,12 @@ public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient { "startTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(start),"endTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(end))); } + public JsonNode inventoryFlowPage(SelfopRequestContext context,String warehouse,LocalDateTime start,LocalDateTime end,int current,int size) { + gate(context);argument(warehouse,64);paging(current,size,1000); + return post(context,"/inventory-flow-page/query",Map.of("warehouseCode",warehouse,"startTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(start), + "endTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(end),"current",current,"size",size)); + } + private Summary summary(String account, JsonNode n) { List items = null; JsonNode products = n.get("productList"); @@ -168,10 +174,10 @@ public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient { case "/product-inventory/query" -> Operation.PRODUCT_INVENTORY; case "/customers/query" -> Operation.CUSTOMERS; case "/products/query" -> Operation.PRODUCTS; - case "/inventory-flow/query" -> Operation.INVENTORY_FLOW; + case "/inventory-flow/query", "/inventory-flow-page/query" -> Operation.INVENTORY_FLOW; default -> throw failure("EGRESS_OPERATION_INVALID"); }; - int size = op==Operation.INVENTORY_FLOW?1000:fields.containsKey("size") ? ((Number)fields.get("size")).intValue() : 0; + int size = fields.containsKey("size") ? ((Number)fields.get("size")).intValue() : op==Operation.INVENTORY_FLOW?1000:0; String warehouse=(String)fields.get("warehouseCode"); String key=op==Operation.OUTBOUND_DETAIL ? SelfopSupport.json(List.of(fields.get("customerCode"),fields.get("deliveryNo"))) : null; var permission = egress.reserve(context,op,size,warehouse,key,SelfopSupport.hash(fields)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryFlowCollector.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryFlowCollector.java new file mode 100644 index 000000000..81fb96375 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryFlowCollector.java @@ -0,0 +1,121 @@ +package com.baiyue.lastleg.selfoperated.analytics.history; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.selfoperated.analytics.client.*; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baiyue.lastleg.selfoperated.analytics.request.*; +import com.baiyue.lastleg.selfoperated.analytics.sync.SelfopLeaseService; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +/** One page per dispatch. Only the HTTP/page checkpoint holds the shared account lease. */ +@Service +public class InventoryFlowCollector { + private final SelfopAnalyticsProperties config; + private final SelfopSyncTaskMapper tasks; private final SelfopSyncRunMapper runs; private final SelfopSyncStageMapper stages; + private final SelfopCoordinationMapper locks; private final SelfopLeaseService leases; + private final RestLingxingAnalyticsClient client; private final JdbcTemplate jdbc; private final TransactionTemplate tx; + private final String session=UUID.randomUUID().toString(); + public InventoryFlowCollector(SelfopAnalyticsProperties config,SelfopSyncTaskMapper tasks,SelfopSyncRunMapper runs, + SelfopSyncStageMapper stages,SelfopCoordinationMapper locks,SelfopLeaseService leases,RestLingxingAnalyticsClient client, + JdbcTemplate jdbc,TransactionTemplate tx){this.config=config;this.tasks=tasks;this.runs=runs;this.stages=stages;this.locks=locks; + this.leases=leases;this.client=client;this.jdbc=jdbc;this.tx=tx;} + public record Checkpoint(String session,int nextPage,LocalDateTime lastTime) {} + public void plan(long accountId,InventoryFlowSlice slice,LocalDate round) { + String digest=hash(slice),key="continuous:history:"+round+":"+digest; + tx.executeWithoutResult(s->{ + locks.lockDiscovery(accountId); + if(tasks.selectCount(new QueryWrapper().eq("source_account",config.getSourceAccount()).eq("request_key",key))>0)return; + var now=utcNow();var task=new SelfopSyncTask();task.setId(IdWorker.getId()); + task.setTaskNo("LXHIST-"+UUID.randomUUID().toString().replace("-",""));task.setSourceAccount(config.getSourceAccount()); + task.setTaskRole("ACCOUNT_DISCOVERY");task.setDiscoveryScopeId(accountId);task.setTaskType(InventoryFlowSlice.TYPE); + task.setExecutionPurpose("BUSINESS");task.setBusinessRootTaskId(task.getId());task.setBusinessBudgetJson(SelfopBusinessBudget.freezeContinuous(now)); + task.setRequestKey(key);task.setRequestHash(digest);task.setScopeHash(digest);task.setQueryScopeJson(json(slice)); + task.setQueryFromUtc(LocalDateTime.ofInstant(slice.from(),ZoneOffset.UTC));task.setQueryUntilUtc(LocalDateTime.ofInstant(slice.until(),ZoneOffset.UTC)); + task.setPlannedAtUtc(round.atStartOfDay());task.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER); + task.setStatus("READY");task.setApplyState("NOT_APPLICABLE");task.setAttemptCount(0);task.setCreatedAt(now);task.setUpdatedAt(now);tasks.insert(task); + }); + } + public boolean priorityWork() { + return !locks.dueContinuous(utcNow(),1,config.getSourceAccount()).isEmpty(); + } + public void page() { + if(priorityWork())return; + var pending=tasks.selectList(new QueryWrapper().eq("source_account",config.getSourceAccount()) + .eq("task_type",InventoryFlowSlice.TYPE).in("status",List.of("READY","RUNNING","RETRY_WAIT")) + .and(q->q.isNull("next_retry_at_utc").or().le("next_retry_at_utc",utcNow())).orderByDesc("query_until_utc").orderByAsc("id").last("LIMIT 1")); + if(pending.isEmpty())return; + var selected=pending.get(0);var lease=leases.acquireDiscovery(selected.getDiscoveryScopeId(),"history-"+session); + try { + if(priorityWork())return; + var run=tx.execute(s->{ + leases.verify(lease);var task=locks.lockTask(selected.getId()); + require(Set.of("READY","RUNNING","RETRY_WAIT").contains(task.getStatus()),"HISTORY_TASK_NOT_READY"); + var prior=runs.selectOne(new QueryWrapper().eq("task_id",task.getId()).orderByDesc("attempt_no").last("LIMIT 1")); + if(prior!=null&&"FETCHING".equals(prior.getStatus())&&prior.getProgressSnapshotJson()!=null + &&session.equals(read(prior.getProgressSnapshotJson(),Checkpoint.class).session())) { + prior.setFenceToken(lease.fence());runs.updateById(prior);return prior; + } + if(prior!=null&&"FETCHING".equals(prior.getStatus())) { + prior.setStatus("ABORTED");prior.setErrorCode("HISTORY_RESTART_PAGE_ONE");prior.setFinishedAtUtc(utcNow());runs.updateById(prior); + } + if(task.getAttemptCount()>=config.getMaxAttempts()){task.setStatus("DEAD");tasks.updateById(task);return null;} + var now=utcNow();var next=new SelfopSyncRun();next.setId(IdWorker.getId());next.setTaskId(task.getId()); + next.setAttemptNo(task.getAttemptCount()+1);next.setSourceAccount(task.getSourceAccount());next.setExecutionPurpose("BUSINESS"); + next.setRunRole("ACCOUNT_DISCOVERY");next.setScopeHash(task.getScopeHash());next.setScopeSnapshotJson(task.getQueryScopeJson()); + next.setFenceToken(lease.fence());next.setStatus("FETCHING");next.setLastPhase("FETCHING");next.setCoverageStatus("UNKNOWN"); + next.setPageCount(0);next.setUniqueObjectCount(0L);next.setFetchStartedAtUtc(now);next.setCreatedAt(now);next.setUpdatedAt(now); + next.setProgressSnapshotJson(json(new Checkpoint(session,1,null)));runs.insert(next); + task.setStatus("RUNNING");task.setAttemptCount(next.getAttemptNo());task.setUpdatedAt(now);tasks.updateById(task);return next; + }); + if(run==null)return; + var task=tasks.selectById(selected.getId());var slice=read(task.getQueryScopeJson(),InventoryFlowSlice.class); + var checkpoint=read(run.getProgressSnapshotJson(),Checkpoint.class); + try { + var page=slice.validate(client.inventoryFlowPage(SelfopRequestContext.forTask(task,run,config),slice.warehouse(),slice.start(),slice.end(),checkpoint.nextPage(),slice.size()), + checkpoint.nextPage(),run.getSourceTotalCount(),checkpoint.lastTime()); + tx.executeWithoutResult(s->{ + leases.verify(lease);var current=locks.lockTask(task.getId());require("RUNNING".equals(current.getStatus()),"HISTORY_TASK_STOPPED"); + var now=utcNow(); + for(var row:page.rows()) { + var stage=new SelfopSyncStage();stage.setId(IdWorker.getId());stage.setRunId(run.getId());stage.setStageBatchId(run.getId()); + stage.setLastFetchRunId(run.getId());stage.setWarehouseCode(slice.warehouse());stage.setObjectType("INVENTORY_FLOW"); + stage.setObjectKeyHash(hash(row.path("id").asText()));stage.setContentHash(hash(row));stage.setPayloadJson(json(row)); + stage.setFirstPageNo(checkpoint.nextPage());stage.setObservedAtUtc(now);stage.setValidationStatus("VALID");stage.setCreatedAt(now);stage.setUpdatedAt(now); + stages.insert(stage); // Duplicate IDs across pages roll back this entire page. + } + run.setPageCount(checkpoint.nextPage());run.setSourceTotalCount(page.total());run.setUniqueObjectCount(run.getUniqueObjectCount()+page.rows().size()); + run.setProgressSnapshotJson(json(new Checkpoint(session,checkpoint.nextPage()+1,page.lastTime())));run.setUpdatedAt(now);run.setHeartbeatAtUtc(now); + if(checkpoint.nextPage()>=page.pages()) { + require(run.getUniqueObjectCount()==page.total(),"HISTORY_PAGE_COUNT_MISMATCH"); + run.setStatus("VALIDATED");run.setLastPhase("VALIDATED");run.setFetchFinishedAtUtc(now); + // Coverage becomes usable only after the separate local merge finishes. + current.setStatus("WAITING_SOURCE"); + } + runs.updateById(run);current.setProgressUpdatedAtUtc(now);current.setUpdatedAt(now);tasks.updateById(current); + }); + }catch(EgressRejectedException e) { + tx.executeWithoutResult(s->{leases.verify(lease);jdbc.update("UPDATE ll_selfop_sync_task SET next_retry_at_utc=?,updated_at=? WHERE id=? AND status='RUNNING'", + e.waitUntilUtc()==null?utcNow().plusSeconds(30):e.waitUntilUtc(),utcNow(),task.getId());}); + }catch(RuntimeException e) { + tx.executeWithoutResult(s->{leases.verify(lease);run.setStatus("FAILED");run.setErrorCode("HISTORY_FETCH_FAILED"); + run.setErrorMessage("分页失败,完整片重试;账号不确定结果仍受出口暂停保护");run.setFinishedAtUtc(utcNow());runs.updateById(run); + boolean drift="HISTORY_PAGE_DRIFT_OR_INVALID".equals(e.getMessage())||e instanceof org.springframework.dao.DuplicateKeyException; + long seconds=Duration.between(slice.start(),slice.end()).getSeconds(); + if(drift&&seconds>3600) { + var midpoint=slice.start().plusSeconds(seconds/2); + plan(task.getDiscoveryScopeId(),new InventoryFlowSlice(slice.warehouse(),slice.start(),midpoint,1000),task.getPlannedAtUtc().toLocalDate()); + plan(task.getDiscoveryScopeId(),new InventoryFlowSlice(slice.warehouse(),midpoint,slice.end(),1000),task.getPlannedAtUtc().toLocalDate()); + } + jdbc.update("UPDATE ll_selfop_sync_task SET status=?,next_retry_at_utc=?,updated_at=? WHERE id=? AND status='RUNNING'", + drift&&seconds>3600||task.getAttemptCount()>=config.getMaxAttempts()?"DEAD":"RETRY_WAIT",utcNow().plusSeconds(60),utcNow(),task.getId());}); + } + }finally{leases.release(lease);} + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryFlowFacts.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryFlowFacts.java new file mode 100644 index 000000000..c224442e1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryFlowFacts.java @@ -0,0 +1,91 @@ +package com.baiyue.lastleg.selfoperated.analytics.history; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.support.TransactionTemplate; +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.*; +import java.util.*; + +/** Small local transactions; facts and invalidation commit together, independently of HTTP. */ +@Repository +public class InventoryFlowFacts { + private final JdbcTemplate jdbc;private final TransactionTemplate tx;private final InventoryHistoryVersions versions; + public InventoryFlowFacts(JdbcTemplate jdbc,TransactionTemplate tx,InventoryHistoryVersions versions){this.jdbc=jdbc;this.tx=tx;this.versions=versions;} + public boolean mergeNext(String account) { + var pending=jdbc.queryForList("SELECT r.id,t.id task_id,t.query_scope_json FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE t.source_account=? AND t.task_type=? AND t.status='WAITING_SOURCE' AND r.status='VALIDATED' ORDER BY r.id LIMIT 1",account,InventoryFlowSlice.TYPE); + if(pending.isEmpty())return false; + long run=((Number)pending.get(0).get("id")).longValue(),task=((Number)pending.get(0).get("task_id")).longValue(); + var slice=SelfopSupport.read(string(pending.get(0).get("query_scope_json")),InventoryFlowSlice.class); + tx.executeWithoutResult(s->{ + versions.lockWarehouse(account,slice.warehouse()); + var state=jdbc.queryForMap("SELECT status,COALESCE(applied_fact_revision,0) checkpoint FROM ll_selfop_sync_run WHERE id=? FOR UPDATE",run); + if(!"VALIDATED".equals(state.get("status")))return; + long after=((Number)state.get("checkpoint")).longValue(); + var batch=jdbc.queryForList("SELECT id,payload_json,content_hash,observed_at_utc FROM ll_selfop_sync_stage WHERE run_id=? AND object_type='INVENTORY_FLOW' AND id>? ORDER BY id LIMIT 500",run,after); + boolean changed=false; + for(var stage:batch)changed|=merge(account,slice,run,stage); + if(changed)versions.invalidate(account,slice.warehouse(),run,"FLOW_FACT_REVISED"); + if(!batch.isEmpty()) { + jdbc.update("UPDATE ll_selfop_sync_run SET applied_fact_revision=?,updated_at=? WHERE id=?",batch.get(batch.size()-1).get("id"),now(),run);return; + } + // A previously observed row disappearing is uncertainty, not an invented reversal. + int missing=jdbc.update("UPDATE ll_selfop_inventory_flow SET validation_status='UNKNOWN',validation_reason='SOURCE_FLOW_MISSING',revision_no=revision_no+1 WHERE source_account=? AND warehouse_code=? AND event_at_utc>=? AND event_at_utc? AND last_observed_at<(SELECT fetch_started_at_utc FROM ll_selfop_sync_run WHERE id=?) LIMIT 100", + account,slice.warehouse(),utc(slice.from()),utc(slice.until()),run,run); + if(missing>0){versions.invalidate(account,slice.warehouse(),run,"SOURCE_FLOW_MISSING");return;} + jdbc.update("UPDATE ll_selfop_sync_run SET status='FACTS_APPLIED',last_phase='FACTS_APPLIED',coverage_status='COMPLETE',covered_from_utc=?,covered_until_utc=?,finished_at_utc=?,updated_at=? WHERE id=?", + utc(slice.from()),utc(slice.until()),now(),now(),run); + jdbc.update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED',updated_at=? WHERE id=? AND status='WAITING_SOURCE'",now(),task); + }); + return true; + } + private boolean merge(String account,InventoryFlowSlice slice,long run,Map stage) { + var row=SelfopSupport.read(string(stage.get("payload_json")),JsonNode.class);String id=row.path("id").asText(); + var old=jdbc.queryForList("SELECT id,customer_code,sku_code,product_quality,content_hash,revision_no,validation_reason,last_observed_at FROM ll_selfop_inventory_flow WHERE source_account=? AND warehouse_code=? AND source_flow_id=?",account,slice.warehouse(),id); + var observed=instant(stage.get("observed_at_utc")); + if(!old.isEmpty()&&instant(old.get(0).get("last_observed_at")).isAfter(observed))return false; + if(!old.isEmpty()) { + var prior=old.get(0); + if("FLOW_IDENTITY_CONFLICT".equals(prior.get("validation_reason")))return false; + if(!Objects.equals(prior.get("customer_code"),row.path("customerCode").asText()) + ||!Objects.equals(prior.get("sku_code"),row.path("productSku").asText()) + ||((Number)prior.get("product_quality")).intValue()!=row.path("productQuality").asInt(-1)) { + jdbc.update("UPDATE ll_selfop_inventory_flow SET validation_status='UNKNOWN',validation_reason='FLOW_IDENTITY_CONFLICT',revision_no=revision_no+1 WHERE id=?",prior.get("id")); + return true; + } + } + if(!old.isEmpty()&&Objects.equals(old.get(0).get("content_hash"),stage.get("content_hash"))&&old.get(0).get("validation_reason")==null){ + jdbc.update("UPDATE ll_selfop_inventory_flow SET last_run_id=?,last_observed_at=? WHERE id=?",run,utc(observed),old.get(0).get("id"));return false; + } + var fields=new LinkedHashMap(); + fields.put("source_account",account);fields.put("warehouse_code",slice.warehouse());fields.put("source_flow_id",id); + fields.put("customer_code",row.path("customerCode").asText());fields.put("sku_code",row.path("productSku").asText());fields.put("product_quality",row.path("productQuality").asInt(-1)); + for(var name:Map.of("cell_no","cellNo","inner_batch_no","innerBatchNo","bill_no","billNo","source_no","sourceNo","bill_type","billType").entrySet())fields.put(name.getKey(),nullable(row,name.getValue())); + var at=LocalDateTime.parse(row.path("createTime").asText().replace(' ','T')); + fields.put("book_qty",number(row,"bookQty"));fields.put("book_change_qty_raw",nullable(row,"bookChangQty"));fields.put("delta_quantity",number(row,"bookChangQty"));fields.put("close_quantity",number(row,"closeQty")); + fields.put("source_create_time",row.path("createTime").asText());fields.put("event_at_utc",at.minusHours(8));fields.put("business_date",at.toLocalDate());fields.put("quantity_unit","PCS"); + fields.put("content_hash",stage.get("content_hash"));fields.put("stage_id",stage.get("id"));fields.put("last_run_id",run);fields.put("last_observed_at",utc(observed)); + fields.put("validation_status","VALID");fields.put("validation_reason",null); + fields.put("revision_no",old.isEmpty()?1L:((Number)old.get(0).get("revision_no")).longValue()+1); + var named=new NamedParameterJdbcTemplate(jdbc); + if(old.isEmpty()) { + fields.put("id",IdWorker.getId());fields.put("first_run_id",run); + named.update("INSERT INTO ll_selfop_inventory_flow ("+String.join(",",fields.keySet())+") VALUES (:"+String.join(",:",fields.keySet())+")",fields); + }else{ + String sets=String.join(",",fields.keySet().stream().map(k->k+"=:"+k).toList());fields.put("id",old.get(0).get("id")); + named.update("UPDATE ll_selfop_inventory_flow SET "+sets+" WHERE id=:id",fields); + } + return true; + } + static BigDecimal number(JsonNode row,String field){try{var v=new BigDecimal(row.path(field).asText());return v.scale()<=6&&v.precision()-v.scale()<=14?v:null;}catch(RuntimeException e){return null;}} + static String nullable(JsonNode row,String field){return row.path(field).isNull()||row.path(field).isMissingNode()?null:row.path(field).asText();} + static String string(Object value){return value instanceof byte[] b?new String(b,java.nio.charset.StandardCharsets.UTF_8):String.valueOf(value);} + static Instant instant(Object v){return v instanceof Timestamp t?t.toLocalDateTime().toInstant(ZoneOffset.UTC):((LocalDateTime)v).toInstant(ZoneOffset.UTC);} + static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC);} + private static LocalDateTime utc(Instant instant){return LocalDateTime.ofInstant(instant,ZoneOffset.UTC);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryFlowSlice.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryFlowSlice.java new file mode 100644 index 000000000..5f0e62099 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryFlowSlice.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.selfoperated.analytics.history; + +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport; +import com.fasterxml.jackson.databind.JsonNode; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.util.*; + +/** Fixed inclusive provider bounds; persisted coverage is half open at the next second. */ +public record InventoryFlowSlice(String warehouse, LocalDateTime start, LocalDateTime end, int size) { + public static final String TYPE="INVENTORY_FLOW_HISTORY"; + public InventoryFlowSlice { + if(warehouse==null||warehouse.isBlank()||warehouse.length()>64||start==null||end==null + ||!start.isBefore(end)||end.isAfter(start.plusDays(1))||start.getNano()!=0||end.getNano()!=0||size!=1000) + throw new IllegalArgumentException("HISTORY_SLICE_INVALID"); + } + public Map request(int page) { + if(page<1)throw new IllegalArgumentException("HISTORY_PAGE_INVALID"); + return Map.of("warehouseCode",warehouse,"startTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(start), + "endTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(end),"current",page,"size",size); + } + public String requestHash(int page){return SelfopSupport.hash(request(page));} + public Instant from(){return start.toInstant(ZoneOffset.ofHours(8));} + public Instant until(){return end.plusSeconds(1).toInstant(ZoneOffset.ofHours(8));} + public record Page(long total,long pages,LocalDateTime lastTime,List rows) {} + public Page validate(JsonNode response,int page,Long previousTotal,LocalDateTime previousLast) { + if(response==null||!response.path("records").isArray()||response.path("current").asInt()!=page + ||response.path("size").asInt()!=size)throw invalid(); + // Internal Long fields are serialized as decimal strings by LongIdJacksonConfiguration. + var totalNode=response.path("total"); + if(!(totalNode.isIntegralNumber()||totalNode.isTextual())||!totalNode.asText().matches("[0-9]{1,19}"))throw invalid(); + long total; + try{total=Long.parseLong(totalNode.asText());}catch(NumberFormatException e){throw invalid();} + long pages=total==0?0:(total-1)/size+1; + if(total<0||pages>Integer.MAX_VALUE||response.path("pages").asLong(-1)!=pages + ||previousTotal!=null&&total!=previousTotal||page>Math.max(1,pages) + ||response.path("records").size()!=Math.min(size,Math.max(0,total-(long)(page-1)*size)))throw invalid(); + var ids=new HashSet();var rows=new ArrayList();var last=previousLast; + for(var row:response.path("records")) { + String id=text(row,"id",64);text(row,"customerCode",64);text(row,"productSku",128); + if(!warehouse.equals(text(row,"whCode",64))||!ids.add(id))throw invalid(); + LocalDateTime at; + try{at=LocalDateTime.parse(row.path("createTime").asText().replace(' ','T'));}catch(RuntimeException e){throw invalid();} + if(at.isBefore(start)||at.isAfter(end)||last!=null&&at.isBefore(last))throw invalid(); + last=at;rows.add(row); + } + return new Page(total,pages,last,List.copyOf(rows)); + } + static String text(JsonNode row,String field,int max) { + var value=row.path(field);String text=value.asText(); + if(!value.isTextual()||text.isBlank()||text.length()>max)throw invalid();return text; + } + private static IllegalStateException invalid(){return new IllegalStateException("HISTORY_PAGE_DRIFT_OR_INVALID");} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryBuilder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryBuilder.java new file mode 100644 index 000000000..f471266e0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryBuilder.java @@ -0,0 +1,186 @@ +package com.baiyue.lastleg.selfoperated.analytics.history; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.history.InventoryHistoryCalculator.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +/** Local reconstruction worker. Source requests and account leases are deliberately absent. */ +@Service +public class InventoryHistoryBuilder { + public static final String TYPE="INVENTORY_HISTORY_BUILD"; + private final JdbcTemplate jdbc;private final TransactionTemplate tx;private final InventoryHistoryVersions versions; + private final SelfopSyncTaskMapper tasks;private final SelfopSyncRunMapper runs;private final SelfopSyncStageMapper stages; + public InventoryHistoryBuilder(JdbcTemplate jdbc,TransactionTemplate tx,InventoryHistoryVersions versions, + SelfopSyncTaskMapper tasks,SelfopSyncRunMapper runs,SelfopSyncStageMapper stages){this.jdbc=jdbc;this.tx=tx;this.versions=versions;this.tasks=tasks;this.runs=runs;this.stages=stages;} + public record StockRun(long id,String scopeHash,Instant from,Instant until) {} + public StockRun anchor(String account,String warehouse,int offset) { + var rows=jdbc.query("SELECT r.id,r.scope_hash,r.fetch_started_at_utc,r.fetch_finished_at_utc FROM ll_selfop_sync_run r JOIN ll_selfop_sync_scope s ON s.id=r.scope_id AND s.scope_hash=r.scope_hash WHERE s.source_account=? AND s.warehouse_code=? AND s.data_family='INVENTORY' AND r.status='PUBLISHED' AND r.execution_purpose='BUSINESS' AND r.coverage_status='COMPLETE' AND r.source_total_count=r.unique_object_count AND r.page_count>0 AND r.fetch_started_at_utc<=r.fetch_finished_at_utc ORDER BY r.published_at_utc DESC,r.id DESC LIMIT 1 OFFSET ?", + (rs,n)->new StockRun(rs.getLong(1),rs.getString(2),rs.getTimestamp(3).toLocalDateTime().toInstant(ZoneOffset.UTC),rs.getTimestamp(4).toLocalDateTime().toInstant(ZoneOffset.UTC)),account,warehouse,offset); + return rows.isEmpty()?null:rows.get(0); + } + private record Frozen(InventoryHistoryVersions.Build build,List candidates, + long task,StockRun anchor,StockRun reference,LocalDate from,LocalDate until,List coverage,List coverageRuns) {} + public void build(String account,String warehouse,int days) { + Frozen frozen=tx.execute(s->freeze(account,warehouse,Math.max(30,Math.min(60,days)))); + if(frozen==null)return; + try { + long count=0;String owner="",sku=""; + while(true) { + // Keyset paging bounds memory independently of the warehouse's SKU count. + var identities=candidates(frozen,owner,sku); + if(identities.isEmpty())break; + var dailyBatches=new HashMap>(); + for(var identity:identities) { + var input=input(frozen,identity);var daily=reconstruct(input); + String crosscheck=crosscheck(input.anchors(),referenceAnchors(frozen,identity),input.flows(),frozen.coverage()); + if(crosscheck!=null)daily=daily.stream().map(d->new Daily(d.date(),d.key(),null,"UNKNOWN",crosscheck,d.anchorRun())).toList(); + var evidence=flowEvidence(frozen,identity); + String evidenceHash=hash(evidence); + tx.executeWithoutResult(s->{ + requireBuilding(frozen);saveInput(frozen,identity,evidence,evidenceHash); + }); + for(var d:daily)dailyBatches.computeIfAbsent(d.date(),k->new ArrayList<>()).add(d); + owner=identity.owner();sku=identity.sku();count++; + } + for(var candidate:frozen.candidates()) { + var rows=dailyBatches.getOrDefault(candidate.date(),List.of()); + for(int start=0;start{ + versions.lockWarehouse(account,warehouse);requireBuilding(frozen); + versions.publish(frozen.build(),frozen.candidates()); + jdbc.update("UPDATE ll_selfop_sync_run SET status='FACTS_APPLIED',last_phase='HISTORY_PUBLISHED',published_at_utc=?,finished_at_utc=?,updated_at=? WHERE id=?",utcNow(),utcNow(),utcNow(),frozen.build().run()); + jdbc.update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED',result_published_run_id=?,updated_at=? WHERE id=?",frozen.build().run(),utcNow(),frozen.task()); + }); + }catch(RuntimeException failure) { + tx.executeWithoutResult(s->{ + versions.lockWarehouse(account,warehouse); + jdbc.update("UPDATE ll_selfop_inventory_day_version SET status='FAILED' WHERE build_run_id=? AND status IN ('BUILDING','READY')",frozen.build().run()); + jdbc.update("UPDATE ll_selfop_sync_run SET status='FAILED',error_code='HISTORY_BUILD_FAILED',error_message='输入变化或构建失败,保留候选证据等待下轮重新构建',finished_at_utc=?,updated_at=? WHERE id=? AND status='BUILDING'",utcNow(),utcNow(),frozen.build().run()); + jdbc.update("UPDATE ll_selfop_sync_task SET status='DEAD',updated_at=? WHERE id=? AND status='RUNNING'",utcNow(),frozen.task()); + }); + throw failure; + } + } + private Frozen freeze(String account,String warehouse,int days) { + versions.lockWarehouse(account,warehouse); + var active=jdbc.queryForList("SELECT t.id,r.id run_id,r.heartbeat_at_utc FROM ll_selfop_sync_task t JOIN ll_selfop_sync_run r ON r.task_id=t.id WHERE t.source_account=? AND JSON_UNQUOTE(JSON_EXTRACT(t.query_scope_json,'$.warehouse'))=? AND t.task_type=? AND t.status='RUNNING' AND r.status='BUILDING' FOR UPDATE",account,warehouse,TYPE); + if(!active.isEmpty()) { + var row=active.get(0);var beat=row.get("heartbeat_at_utc"); + if(beat!=null&&InventoryFlowFacts.instant(beat).isAfter(Instant.now().minusSeconds(300)))return null; + jdbc.update("UPDATE ll_selfop_inventory_day_version SET status='FAILED' WHERE build_run_id=? AND status IN ('BUILDING','READY')",row.get("run_id")); + jdbc.update("UPDATE ll_selfop_sync_run SET status='ABORTED',error_code='HISTORY_BUILD_INTERRUPTED',finished_at_utc=? WHERE id=?",utcNow(),row.get("run_id")); + jdbc.update("UPDATE ll_selfop_sync_task SET status='DEAD',updated_at=? WHERE id=?",utcNow(),row.get("id")); + } + // A quarantined identity cannot silently move to a different customer/SKU/quality. + if(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_flow WHERE source_account=? AND warehouse_code=? AND validation_reason='FLOW_IDENTITY_CONFLICT'",Long.class,account,warehouse)>0)return null; + var anchor=anchor(account,warehouse,0);var reference=anchor(account,warehouse,1); + if(anchor==null||reference==null)return null; + LocalDate until=anchor.from().atOffset(ZoneOffset.ofHours(8)).toLocalDate(); + Instant retainedFrom=until.minusDays(366).atStartOfDay().toInstant(ZoneOffset.ofHours(8)); + Instant coverageFrom=reference.from().isBefore(retainedFrom)?reference.from():retainedFrom; + var coverageRows=jdbc.queryForList("SELECT r.id,r.covered_from_utc,r.covered_until_utc FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE t.source_account=? AND t.task_type=? AND r.status='FACTS_APPLIED' AND r.coverage_status='COMPLETE' AND JSON_UNQUOTE(JSON_EXTRACT(t.query_scope_json,'$.warehouse'))=? AND r.covered_until_utc>? ORDER BY r.covered_from_utc",account,InventoryFlowSlice.TYPE,warehouse,utc(coverageFrom)); + var coverage=union(coverageRows.stream().map(r->new Interval(InventoryFlowFacts.instant(r.get("covered_from_utc")),InventoryFlowFacts.instant(r.get("covered_until_utc")))).toList()); + var coverageRuns=coverageRows.stream().map(r->((Number)r.get("id")).longValue()).toList(); + LocalDate from=until; + while(from.isAfter(until.minusDays(days))&&covered(coverage,from.minusDays(1).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),anchor.until().plusNanos(1)))from=from.minusDays(1); + if(from.equals(until))return null; + var heads=versions.heads(account,warehouse,until.minusDays(366),until); + // Corrections rebuild every retained date, or remain invalid until its coverage returns. + if(!heads.isEmpty()&&heads.keySet().stream().min(LocalDate::compareTo).orElseThrow().isBefore(from)) { + LocalDate earliest=heads.keySet().iterator().next(); + if(!covered(coverage,earliest.atStartOfDay().toInstant(ZoneOffset.ofHours(8)),anchor.until().plusNanos(1)))return null; + from=earliest; + } + if(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE t.source_account=? AND t.task_type=? AND r.status='VALIDATED' AND JSON_UNQUOTE(JSON_EXTRACT(t.query_scope_json,'$.warehouse'))=? AND t.query_until_utc>? AND t.query_from_utc0)return null; + var sales=jdbc.queryForList("SELECT published_run_id FROM ll_selfop_sync_scope WHERE source_account=? AND warehouse_code=? AND data_family='OUTBOUND'",account,warehouse); + Long salesRun=sales.isEmpty()||sales.get(0).get("published_run_id")==null?null:((Number)sales.get(0).get("published_run_id")).longValue(); + String inputHash=versions.factHash(account,warehouse); + String signature=hash(Arrays.asList(inputHash,anchor,reference,from,until,salesRun,coverage)); + if(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=? AND task_type=? AND request_hash=? AND status='SUCCEEDED'",Long.class,account,TYPE,signature)>0)return null; + // Failed identical inputs get bounded attempts, instead of an infinite hot loop. + if(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=? AND task_type=? AND request_hash=?",Long.class,account,TYPE,signature)>=3)return null; + long taskId=IdWorker.getId(),runId=IdWorker.getId(),group=IdWorker.getId();var now=utcNow(); + String evidence=json(Map.of("warehouse",warehouse,"anchor",anchor,"reference",reference,"coverageRuns",coverageRuns,"inputStagesRun",runId,"rule",RULE)); + var task=new SelfopSyncTask();task.setId(taskId);task.setTaskNo("LXRESTORE-"+UUID.randomUUID().toString().replace("-","")); + task.setSourceAccount(account);task.setDiscoveryScopeId(jdbc.queryForObject("SELECT id FROM ll_selfop_outbound_discovery_scope WHERE source_account=?",Long.class,account));task.setExecutionPurpose("BUSINESS"); + task.setTaskRole("ACCOUNT_DISCOVERY");task.setTaskType(TYPE);task.setRequestKey("history-build:"+UUID.randomUUID());task.setRequestHash(signature); + task.setScopeHash(anchor.scopeHash());task.setQueryScopeJson(evidence);task.setStatus("RUNNING");task.setApplyState("NOT_APPLICABLE");task.setAttemptCount(1);task.setCreatedAt(now);task.setUpdatedAt(now);tasks.insert(task); + var run=new SelfopSyncRun();run.setId(runId);run.setTaskId(taskId);run.setSourceAccount(account); + run.setExecutionPurpose("BUSINESS");run.setRunRole("ACCOUNT_DISCOVERY");run.setScopeHash(anchor.scopeHash());run.setScopeSnapshotJson(evidence);run.setFenceToken(0L);run.setAttemptNo(1); + run.setBasePublishedRunId(anchor.id());run.setReferenceSalesRunId(salesRun);run.setStatus("BUILDING");run.setLastPhase("BUILDING");run.setCoverageStatus("COMPLETE");run.setPageCount(0);run.setUniqueObjectCount(0L); + run.setHeartbeatAtUtc(now);run.setCreatedAt(now);run.setUpdatedAt(now);runs.insert(run); + var build=new InventoryHistoryVersions.Build(account,warehouse,anchor.scopeHash(),runId,group,anchor.id(),salesRun,inputHash,evidence,Map.copyOf(heads)); + var candidates=new ArrayList();for(LocalDate day=from;day.isBefore(until);day=day.plusDays(1))candidates.add(versions.create(build,day)); + jdbc.update("UPDATE ll_selfop_sync_run SET progress_snapshot_json=? WHERE id=?",json(Map.of("candidates",candidates,"expectedHeads",heads,"inputHash",inputHash)),runId); + return new Frozen(build,List.copyOf(candidates),taskId,anchor,reference,from,until,coverage,coverageRuns); + } + private List candidates(Frozen f,String afterOwner,String afterSku) { + return jdbc.query("SELECT customer_code,sku_code FROM (SELECT customer_code,sku_code FROM ll_selfop_inventory_snapshot WHERE snapshot_run_id=? UNION SELECT customer_code,sku_code FROM ll_selfop_sku_daily_sales WHERE sales_run_id=? AND business_date>=? AND business_date? OR (customer_code=? AND sku_code>?) ORDER BY customer_code,sku_code LIMIT 100", + (rs,n)->new Sku(rs.getString(1),rs.getString(2)),f.anchor().id(),f.build().sales(),f.from(),f.until(),f.build().account(),f.build().warehouse(),f.build().account(),f.build().warehouse(),afterOwner,afterOwner,afterSku); + } + private List anchors(StockRun run,Sku sku) { + return jdbc.query("SELECT s.product_quality,s.physical_quantity,s.quality_status,s.quality_reason,p.quantity_unit FROM ll_selfop_inventory_snapshot s LEFT JOIN ll_selfop_sku_profile p ON p.profile_run_id=s.snapshot_run_id AND p.customer_code=s.customer_code AND p.sku_code=s.sku_code WHERE s.snapshot_run_id=? AND s.customer_code=? AND s.sku_code=?", + (rs,n)->new Anchor(new Key(sku,rs.getInt(1)),("VALID".equals(rs.getString(3))||Set.of("FIELD_MISSING","NEGATIVE_QUANTITY").contains(Objects.toString(rs.getString(4),"")))?rs.getBigDecimal(2):null, + rs.getString(5),run.from(),run.until(),run.id()),run.id(),sku.owner(),sku.sku()); + } + private List referenceAnchors(Frozen f,Sku sku){return anchors(f.reference(),sku);} + private Input input(Frozen f,Sku sku) { + Instant dayStart=f.from().atStartOfDay().toInstant(ZoneOffset.ofHours(8)); + Instant referenceStart=f.reference().from().truncatedTo(java.time.temporal.ChronoUnit.SECONDS); + Instant evidenceFrom=referenceStart.isBefore(dayStart)?referenceStart:dayStart; + var flows=jdbc.query("SELECT source_flow_id,product_quality,cell_no,inner_batch_no,bill_type,delta_quantity,book_qty,close_quantity,event_at_utc,validation_reason FROM ll_selfop_inventory_flow WHERE source_account=? AND warehouse_code=? AND customer_code=? AND sku_code=? AND (event_at_utc IS NULL OR (event_at_utc>=? AND event_at_utc<=?)) ORDER BY event_at_utc,source_flow_id", + (rs,n)->new Flow(rs.getString(1),new Key(sku,rs.getInt(2)),rs.getString(3),rs.getString(4),rs.getString(5),rs.getString(10)==null?rs.getBigDecimal(6):null,rs.getBigDecimal(7),rs.getBigDecimal(8),rs.getTimestamp(9)==null?null:rs.getTimestamp(9).toLocalDateTime().toInstant(ZoneOffset.UTC)), + f.build().account(),f.build().warehouse(),sku.owner(),sku.sku(),utc(evidenceFrom),utc(f.anchor().until())); + return new Input(f.from(),f.until(),Set.of(sku),flows,anchors(f.anchor(),sku),f.coverage()); + } + private Object flowEvidence(Frozen f,Sku sku) { + return Map.of("sku",sku,"anchor",f.anchor(),"reference",f.reference(),"flowStages",jdbc.queryForList("SELECT source_flow_id,revision_no,stage_id,validation_reason FROM ll_selfop_inventory_flow WHERE source_account=? AND warehouse_code=? AND customer_code=? AND sku_code=? ORDER BY source_flow_id",f.build().account(),f.build().warehouse(),sku.owner(),sku.sku())); + } + static String crosscheck(List current,List prior,List flows,List coverage) { + if(current.isEmpty()||prior.isEmpty())return "INDEPENDENT_ANCHOR_MISSING"; + if(!current.stream().map(Anchor::key).collect(java.util.stream.Collectors.toSet()).equals(prior.stream().map(Anchor::key).collect(java.util.stream.Collectors.toSet())))return "INDEPENDENT_ANCHOR_QUALITY_MISSING"; + for(var a:current) { + var p=prior.stream().filter(v->v.key().equals(a.key())).findFirst().orElseThrow(); + if(p.quantity()==null||a.quantity()==null||!"PCS".equals(p.unit())||!"PCS".equals(a.unit()))return "INDEPENDENT_ANCHOR_INVALID"; + if(!p.observedUntil().isBefore(a.observedFrom())||!covered(coverage,p.observedFrom(),a.observedUntil().plusNanos(1)))return "INDEPENDENT_ANCHOR_COVERAGE_MISSING"; + BigDecimal expected=p.quantity(); + for(var flow:flows)if(flow.key().equals(a.key())) { + if(flow.at()==null||flow.delta()==null)return "INDEPENDENT_ANCHOR_FLOW_UNKNOWN"; + if((!flow.at().isBefore(p.observedFrom().truncatedTo(java.time.temporal.ChronoUnit.SECONDS))&&!flow.at().isAfter(p.observedUntil())) + ||(!flow.at().isBefore(a.observedFrom().truncatedTo(java.time.temporal.ChronoUnit.SECONDS))&&!flow.at().isAfter(a.observedUntil())))return "ANCHOR_TIME_AMBIGUOUS"; + if(flow.at().isAfter(p.observedUntil())&&flow.at().isBefore(a.observedFrom()))expected=expected.add(flow.delta()); + } + if(expected.compareTo(a.quantity())!=0)return "INDEPENDENT_ANCHOR_MISMATCH"; + } + return null; + } + private void saveInput(Frozen f,Sku sku,Object evidence,String digest) { + var stage=new SelfopSyncStage();stage.setId(IdWorker.getId());stage.setRunId(f.build().run());stage.setStageBatchId(f.build().run());stage.setLastFetchRunId(f.build().run());stage.setWarehouseCode(f.build().warehouse()); + stage.setObjectType("HISTORY_INPUT");stage.setObjectKeyHash(hash(sku));stage.setContentHash(digest);stage.setPayloadJson(json(evidence));stage.setFirstPageNo(1);stage.setValidationStatus("VALID");stage.setObservedAtUtc(utcNow());stage.setCreatedAt(utcNow());stage.setUpdatedAt(utcNow());stages.insert(stage); + } + private void requireBuilding(Frozen f){require("BUILDING".equals(jdbc.queryForObject("SELECT status FROM ll_selfop_sync_run WHERE id=? FOR UPDATE",String.class,f.build().run())),"HISTORY_BUILD_LOST");} + private void heartbeat(Frozen f){jdbc.update("UPDATE ll_selfop_sync_run SET heartbeat_at_utc=?,updated_at=? WHERE id=? AND status='BUILDING'",utcNow(),utcNow(),f.build().run());} + private static List union(List input) { + var result=new ArrayList(); + for(var next:input.stream().sorted(Comparator.comparing(Interval::from)).toList()) { + if(result.isEmpty()||result.get(result.size()-1).until().isBefore(next.from()))result.add(next); + else {var old=result.remove(result.size()-1);result.add(new Interval(old.from(),old.until().isAfter(next.until())?old.until():next.until()));} + } + return List.copyOf(result); + } + private static LocalDateTime utc(Instant instant){return LocalDateTime.ofInstant(instant,ZoneOffset.UTC);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryCalculator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryCalculator.java new file mode 100644 index 000000000..ec4d54a15 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryCalculator.java @@ -0,0 +1,141 @@ +package com.baiyue.lastleg.selfoperated.analytics.history; + +import java.math.BigDecimal; +import java.time.*; +import java.util.*; + +/** Pure reconstruction over frozen, complete source intervals. Never invents absent stock. */ +public final class InventoryHistoryCalculator { + public static final String RULE = "FLOW_EOD_V1"; + private static final Set VERIFIED_TYPES = Set.of("DO", "PA", "IMO", "IAT", "RI"); + private static final ZoneOffset BUSINESS = ZoneOffset.ofHours(8); + private InventoryHistoryCalculator() { } + public record Sku(String owner, String sku) { } + public record Key(Sku sku, int quality) { } + public record Interval(Instant from, Instant until) { + public Interval { if (from == null || until == null || !from.isBefore(until)) throw new IllegalArgumentException("INVALID_COVERAGE"); } + } + public record Flow(String id, Key key, String cell, String batch, String type, + BigDecimal delta, BigDecimal bookQuantity, BigDecimal closing, Instant at) { } + public record Anchor(Key key, BigDecimal quantity, String unit, Instant observedFrom, Instant observedUntil, long runId) { } + public record Daily(LocalDate date, Key key, BigDecimal quantity, String status, String reason, long anchorRun) { } + public record Input(LocalDate from, LocalDate until, Set candidates, List flows, + List anchors, List coverage) { } + + public static List reconstruct(Input input) { + Objects.requireNonNull(input); + if (input.from()==null || input.until()==null || !input.from().isBefore(input.until()) + || input.until().isAfter(input.from().plusDays(366))) throw new IllegalArgumentException("INVALID_HISTORY_WINDOW"); + var byKey = new HashMap>(); + var anchors = new HashMap(); + var reasons = new HashMap(); + var ids = new HashMap(); + var keys = new HashSet(); + for (Anchor a : input.anchors()) { + requireKey(a.key()); keys.add(a.key()); + if (anchors.putIfAbsent(a.key(),a)!=null) reasons.put(a.key(),"ANCHOR_DUPLICATE"); + } + for (Flow f : input.flows()) { + requireKey(f.key()); keys.add(f.key()); + if (f.id()==null || f.id().isBlank() || f.id().length()>64) { reasons.put(f.key(),"FLOW_ID_INVALID"); continue; } + Flow old=ids.putIfAbsent(f.id(),f); + if (old!=null) { + if (!old.equals(f)) { reasons.put(old.key(),"FLOW_ID_CONFLICT"); reasons.put(f.key(),"FLOW_ID_CONFLICT"); } + continue; + } + byKey.computeIfAbsent(f.key(),k->new ArrayList<>()).add(f); + } + var observedSkus=new HashSet();for(var key:keys)observedSkus.add(key.sku()); + for (Sku sku : input.candidates()) if (!observedSkus.contains(sku)) keys.add(new Key(sku,0)); + var result = new ArrayList(); + var ordered = keys.stream().sorted(Comparator.comparing((Key k)->k.sku().owner()).thenComparing(k->k.sku().sku()).thenComparingInt(Key::quality)).toList(); + for (Key key : ordered) { + Anchor a=anchors.get(key); + List rows=byKey.getOrDefault(key,List.of()); + String failure=reasons.get(key); + if (failure==null) failure=validateAnchor(a); + if (failure==null && key.quality()!=0 && key.quality()!=1) failure="UNKNOWN_QUALITY"; + if (failure==null) failure=validateRows(rows,a); + for (LocalDate day=input.from();day.isBefore(input.until());day=day.plusDays(1)) { + Instant boundary=day.plusDays(1).atStartOfDay().toInstant(BUSINESS); + String reason=failure; + if (reason==null && a.observedFrom().isBefore(boundary)) reason="ANCHOR_BEFORE_DAY_END"; + if (reason==null && !covered(input.coverage(),boundary,a.observedUntil().plusNanos(1))) reason="FLOW_COVERAGE_GAP"; + BigDecimal quantity=null; + if (reason==null) { + quantity=a.quantity(); + for (Flow f:rows) if (!f.at().isBefore(boundary) && f.at().isBefore(a.observedFrom())) quantity=quantity.subtract(f.delta()); + if (quantity.signum()<0) { quantity=null; reason="NEGATIVE_RECONSTRUCTED_STOCK"; } + } + result.add(new Daily(day,key,quantity,reason==null?"VALID":"UNKNOWN",reason,a==null?0:a.runId())); + } + } + return List.copyOf(result); + } + private static String validateAnchor(Anchor a) { + if (a==null) return "ANCHOR_MISSING"; + if (!"PCS".equals(a.unit())) return "UNIT_UNKNOWN"; + if (!quantity(a.quantity()) || a.quantity().signum()<0) return "ANCHOR_QUANTITY_INVALID"; + if (a.runId()<=0 || a.observedFrom()==null || a.observedUntil()==null || a.observedUntil().isBefore(a.observedFrom())) return "ANCHOR_INTERVAL_INVALID"; + return null; + } + private static String validateRows(List rows, Anchor anchor) { + var chains=new HashMap,List>(); + for (Flow f:rows) { + if (f.at()==null || !quantity(f.delta()) || !quantity(f.bookQuantity()) || !quantity(f.closing())) return "FLOW_QUANTITY_OR_TIME_INVALID"; + if (f.type()==null || !VERIFIED_TYPES.contains(f.type())) return "FLOW_TYPE_UNVERIFIED"; + if (f.delta().compareTo(f.bookQuantity())!=0) return "FLOW_QUANTITY_CONFLICT"; + if (f.closing().signum()<0 || f.closing().subtract(f.delta()).signum()<0) return "NEGATIVE_CHAIN_STOCK"; + if (f.cell()==null || f.cell().isBlank() || f.batch()==null || f.batch().isBlank()) return "CHAIN_IDENTITY_MISSING"; + // Source events have second resolution; an event in the anchor's first second is ambiguous. + if (!f.at().isBefore(anchor.observedFrom().truncatedTo(java.time.temporal.ChronoUnit.SECONDS)) + && !f.at().isAfter(anchor.observedUntil())) return "ANCHOR_TIME_AMBIGUOUS"; + chains.computeIfAbsent(List.of(f.cell(),f.batch()),k->new ArrayList<>()).add(f); + } + for (var chain:chains.values()) { + var seconds=new TreeMap>(); + for(Flow f:chain) seconds.computeIfAbsent(f.at(),k->new ArrayList<>()).add(f); + BigDecimal prior=null; + for(var group:seconds.values()) { + if(group.size()>8) return "CHAIN_ORDER_AMBIGUOUS"; + var endings=new ArrayList(); + paths(group,new boolean[group.size()],prior,0,endings); + if(endings.isEmpty()) return "CHAIN_DISCONTINUITY"; + if(endings.size()!=1) return "CHAIN_ORDER_AMBIGUOUS"; + prior=endings.get(0); + } + } + var deltas=new TreeMap(Comparator.reverseOrder()); + for(var row:rows)if(row.at().isBefore(anchor.observedFrom()))deltas.merge(row.at(),row.delta(),BigDecimal::add); + BigDecimal balance=anchor.quantity(); + for(var delta:deltas.values()) { + balance=balance.subtract(delta); + if(balance.signum()<0)return "NEGATIVE_RECONSTRUCTED_STOCK"; + } + return null; + } + private static void paths(List group,boolean[] used,BigDecimal balance,int depth,List endings) { + if(endings.size()>1) return; + if(depth==group.size()) { endings.add(balance); return; } + for(int i=0;i intervals,Instant from,Instant until) { + Instant cursor=from; + for(Interval i:intervals.stream().sorted(Comparator.comparing(Interval::from)).toList()) { + if(i.until().isBefore(cursor)) continue; + if(i.from().isAfter(cursor)) return false; + if(i.until().isAfter(cursor)) cursor=i.until(); + if(!cursor.isBefore(until)) return true; + } + return false; + } + private static boolean quantity(BigDecimal value) { return value!=null && value.scale()<=6 && value.precision()-value.scale()<=14; } + private static void requireKey(Key k) { + if(k==null || k.sku()==null || k.sku().owner()==null || k.sku().owner().isBlank() || k.sku().sku()==null || k.sku().sku().isBlank()) + throw new IllegalArgumentException("HISTORY_IDENTITY_INVALID"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryVersions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryVersions.java new file mode 100644 index 000000000..1ccfd6b3a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryVersions.java @@ -0,0 +1,100 @@ +package com.baiyue.lastleg.selfoperated.analytics.history; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.support.TransactionTemplate; +import java.sql.Timestamp; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.history.InventoryHistoryCalculator.*; + +/** Historical day heads have their own publication protocol, separate from raw stock runs. */ +@Repository +public class InventoryHistoryVersions { + private final JdbcTemplate jdbc; + private final TransactionTemplate tx; + public InventoryHistoryVersions(JdbcTemplate jdbc,TransactionTemplate tx) { this.jdbc=jdbc;this.tx=tx; } + public record Build(String account,String warehouse,String scopeHash,long run,long group,long anchor, + Long sales,String inputHash,String evidence,Map expectedHeads) { + public Build { expectedHeads=Map.copyOf(expectedHeads); } + } + public record Candidate(long id,LocalDate date) { } + public void lockWarehouse(String account,String warehouse) { + var rows=jdbc.queryForList("SELECT id FROM ll_selfop_sync_scope WHERE source_account=? AND warehouse_code=? AND data_family='INVENTORY' FOR UPDATE",account,warehouse); + if(rows.size()!=1) throw new IllegalStateException("HISTORY_SCOPE_MISSING"); + } + /** Monotone revisions and no deletion make this fingerprint stable under the warehouse lock. */ + public String factHash(String account,String warehouse) { + return SelfopSupport.hash(jdbc.queryForMap("SELECT COUNT(*) n,COALESCE(SUM(revision_no),0) revisions,COALESCE(MAX(id),0) newest FROM ll_selfop_inventory_flow WHERE source_account=? AND warehouse_code=?",account,warehouse)); + } + public Map heads(String account,String warehouse,LocalDate from,LocalDate until) { + var result=new TreeMap(); + jdbc.query("SELECT business_date,id FROM ll_selfop_inventory_day_version WHERE source_account=? AND warehouse_code=? AND head_slot=1 AND business_date>=? AND business_date{result.put(rs.getDate(1).toLocalDate(),rs.getLong(2));},account,warehouse,from,until); + return result; + } + public Candidate create(Build build,LocalDate date) { + long id=IdWorker.getId(); + jdbc.update("INSERT INTO ll_selfop_inventory_day_version (id,source_account,warehouse_code,business_date,scope_hash,rule_version,build_run_id,publication_group_id,sales_run_id,anchor_run_id,input_hash,evidence_json,status,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,'BUILDING',?)", + id,build.account(),build.warehouse(),date,build.scopeHash(),RULE,build.run(),build.group(),build.sales(),build.anchor(),build.inputHash(),build.evidence(),now()); + return new Candidate(id,date); + } + /** Bounded insert transaction; a failed batch never changes visible day heads. */ + public void append(Candidate candidate,List rows,String evidenceHash) { + if(rows.size()>500 || rows.stream().anyMatch(r->!candidate.date().equals(r.date()))) throw new IllegalArgumentException("HISTORY_BATCH_INVALID"); + tx.executeWithoutResult(status->{ + String state=jdbc.queryForObject("SELECT status FROM ll_selfop_inventory_day_version WHERE id=? FOR UPDATE",String.class,candidate.id()); + if(!"BUILDING".equals(state)) throw new IllegalStateException("HISTORY_CANDIDATE_IMMUTABLE"); + jdbc.batchUpdate("INSERT INTO ll_selfop_inventory_daily (day_version_id,customer_code,sku_code,product_quality,physical_quantity,quality_status,quality_reason,anchor_reference,evidence_hash,source) VALUES (?,?,?,?,?,?,?,?,?,'FLOW_RECONSTRUCTED')", + rows,500,(p,r)->{p.setLong(1,candidate.id());p.setString(2,r.key().sku().owner());p.setString(3,r.key().sku().sku());p.setInt(4,r.key().quality()); + p.setBigDecimal(5,r.quantity());p.setString(6,r.status());p.setString(7,r.reason()); + if(r.anchorRun()==0)p.setNull(8,java.sql.Types.BIGINT);else p.setLong(8,r.anchorRun());p.setString(9,evidenceHash);}); + }); + } + public void ready(Candidate candidate,long expectedSkuCount) { + tx.executeWithoutResult(s->{ + String state=jdbc.queryForObject("SELECT status FROM ll_selfop_inventory_day_version WHERE id=? FOR UPDATE",String.class,candidate.id()); + if(!"BUILDING".equals(state)) throw new IllegalStateException("HISTORY_CANDIDATE_IMMUTABLE"); + var totals=jdbc.queryForMap("SELECT COUNT(*) total,COALESCE(SUM(valid),0) valid FROM (SELECT customer_code,sku_code,MIN(CASE WHEN quality_status='VALID' THEN 1 ELSE 0 END) valid FROM ll_selfop_inventory_daily WHERE day_version_id=? GROUP BY customer_code,sku_code) x",candidate.id()); + long count=((Number)totals.get("total")).longValue(),valid=((Number)totals.get("valid")).longValue(); + if(count!=expectedSkuCount) throw new IllegalStateException("HISTORY_CANDIDATE_INCOMPLETE"); + jdbc.update("UPDATE ll_selfop_inventory_day_version SET status='READY',valid_sku_count=?,unknown_sku_count=? WHERE id=?",valid,count-valid,candidate.id()); + }); + } + /** Must be called in the same warehouse-locked transaction that accepts a source revision. */ + public void invalidate(String account,String warehouse,long run,String reason) { + jdbc.update("UPDATE ll_selfop_inventory_day_version SET invalidated_at=COALESCE(invalidated_at,?),invalidation_reason=COALESCE(invalidation_reason,?),rebuild_run_id=? WHERE source_account=? AND warehouse_code=? AND status IN ('PUBLISHED','SUPERSEDED')", + now(),reason,run,account,warehouse); + } + public void publish(Build build,List candidates) { + if(candidates.isEmpty() || candidates.size()>366 || candidates.stream().map(Candidate::date).distinct().count()!=candidates.size()) throw new IllegalArgumentException("HISTORY_GROUP_INVALID"); + tx.executeWithoutResult(s->{ + lockWarehouse(build.account(),build.warehouse()); + var stored=jdbc.queryForList("SELECT id,business_date,status FROM ll_selfop_inventory_day_version WHERE publication_group_id=? AND build_run_id=? AND source_account=? AND warehouse_code=? ORDER BY business_date FOR UPDATE",build.group(),build.run(),build.account(),build.warehouse()); + var expected=candidates.stream().map(Candidate::id).collect(java.util.stream.Collectors.toSet()); + if(stored.size()!=candidates.size() || stored.stream().anyMatch(r->!expected.contains(((Number)r.get("id")).longValue()))) throw new IllegalStateException("HISTORY_GROUP_INCOMPLETE"); + for(var row:stored) { + var candidate=candidates.stream().filter(c->c.id()==((Number)row.get("id")).longValue()).findFirst().orElseThrow(); + if(!candidate.date().toString().equals(row.get("business_date").toString()))throw new IllegalStateException("HISTORY_GROUP_DATE_MISMATCH"); + } + if(stored.stream().allMatch(r->"PUBLISHED".equals(r.get("status")))) return; + if(!build.scopeHash().equals(jdbc.queryForObject("SELECT scope_hash FROM ll_selfop_sync_scope WHERE source_account=? AND warehouse_code=? AND data_family='INVENTORY'",String.class,build.account(),build.warehouse())))throw new IllegalStateException("HISTORY_SCOPE_CHANGED"); + if(!build.inputHash().equals(factHash(build.account(),build.warehouse()))) throw new IllegalStateException("HISTORY_INPUT_CHANGED"); + if(stored.stream().anyMatch(r->!"READY".equals(r.get("status")))) throw new IllegalStateException("HISTORY_GROUP_NOT_READY"); + for(var c:candidates) { + var heads=jdbc.queryForList("SELECT id FROM ll_selfop_inventory_day_version WHERE source_account=? AND warehouse_code=? AND business_date=? AND head_slot=1",Long.class,build.account(),build.warehouse(),c.date()); + Long old=heads.isEmpty()?null:heads.get(0); + if(!Objects.equals(old,build.expectedHeads().get(c.date()))) throw new IllegalStateException("HISTORY_HEAD_CHANGED"); + } + var at=now(); + for(var c:candidates) { + jdbc.update("UPDATE ll_selfop_inventory_day_version SET status='SUPERSEDED',head_slot=NULL,superseded_at=? WHERE source_account=? AND warehouse_code=? AND business_date=? AND head_slot=1",at,build.account(),build.warehouse(),c.date()); + int updated=jdbc.update("UPDATE ll_selfop_inventory_day_version SET status='PUBLISHED',head_slot=1,published_at=? WHERE id=? AND status='READY' AND invalidated_at IS NULL AND input_hash=?",at,c.id(),build.inputHash()); + if(updated!=1) throw new IllegalStateException("HISTORY_CANDIDATE_CHANGED"); + } + }); + } + private static LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryWorker.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryWorker.java new file mode 100644 index 000000000..a7e184c2d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryWorker.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.selfoperated.analytics.history; + +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import java.time.*; +import java.time.temporal.TemporalAdjusters; +import java.util.concurrent.*; + +/** Two bounded lanes: provider pages never wait behind local reconstruction or the sales scheduler. */ +@Component +public class InventoryHistoryWorker { + private static final Logger LOG=LoggerFactory.getLogger(InventoryHistoryWorker.class); + private final SelfopAnalyticsProperties config;private final InventoryFlowCollector collector; + private final InventoryFlowFacts facts;private final InventoryHistoryBuilder builder;private final JdbcTemplate jdbc; + private final ScheduledExecutorService http=Executors.newSingleThreadScheduledExecutor(r->{var t=new Thread(r,"selfop-history-pages");t.setDaemon(true);return t;}); + private final ScheduledExecutorService local=Executors.newSingleThreadScheduledExecutor(r->{var t=new Thread(r,"selfop-history-local");t.setDaemon(true);return t;}); + private Instant nextPlan=Instant.EPOCH; + private Instant nextBuild=Instant.EPOCH; + public InventoryHistoryWorker(SelfopAnalyticsProperties config,InventoryFlowCollector collector,InventoryFlowFacts facts,InventoryHistoryBuilder builder,JdbcTemplate jdbc){this.config=config;this.collector=collector;this.facts=facts;this.builder=builder;this.jdbc=jdbc;} + boolean enabled(){return config.isInventoryHistoryEnabled()&&SelfopCollectionPolicy.continuousBusiness(config)&&"PCS".equals(config.getQuantityUnit())&&"Asia/Shanghai".equals(config.getSourceQueryTimezone());} + @PostConstruct public void start(){http.scheduleWithFixedDelay(()->guard(this::page),10,1,TimeUnit.SECONDS);local.scheduleWithFixedDelay(()->guard(this::local),12000,100,TimeUnit.MILLISECONDS);} + @PreDestroy public void close(){http.shutdownNow();local.shutdownNow();} + private void guard(Runnable action){if(!enabled())return;try{action.run();}catch(RuntimeException e){LOG.warn("Historical inventory worker deferred ({})",e.getClass().getSimpleName());}} + void page(){ + if(Instant.now().isAfter(nextPlan)){plan();nextPlan=Instant.now().plusSeconds(60);} + collector.page(); + } + void local(){ + facts.mergeNext(config.getSourceAccount()); + if(Instant.now().isBefore(nextBuild))return; + nextBuild=Instant.now().plusSeconds(5); + for(String warehouse:config.getWarehouses().keySet())if(enabled())builder.build(config.getSourceAccount(),warehouse,config.getInventoryHistoryDays()); + } + void plan(){ + var now=LocalDateTime.now(ZoneOffset.ofHours(8));if(now.toLocalTime().isBefore(LocalTime.of(0,5)))return; + var ids=jdbc.queryForList("SELECT id FROM ll_selfop_outbound_discovery_scope WHERE source_account=?",Long.class,config.getSourceAccount());if(ids.size()!=1)return; + LocalDate today=now.toLocalDate(); + for(String warehouse:config.getWarehouses().keySet()) { + var anchor=builder.anchor(config.getSourceAccount(),warehouse,0);if(anchor==null)continue; + long published=jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_day_version WHERE source_account=? AND warehouse_code=? AND head_slot=1 AND business_date>=? AND business_date'INVENTORY_FLOW_PROBE' AND status IN ('READY','RETRY_WAIT','RUNNING') AND (execution_purpose='BUSINESS' OR (status='READY' AND validation_root_task_id=id)) AND (next_retry_at_utc IS NULL OR next_retry_at_utc<=#{now}) ORDER BY CASE WHEN task_type IN ('RECHECK','REBUILD') THEN 1 WHEN task_role='WAREHOUSE_SYNC' THEN 0 WHEN task_type='BACKFILL' THEN 3 ELSE 2 END,id LIMIT #{limit}") + @Select("SELECT * FROM ll_selfop_sync_task WHERE execution_purpose=#{purpose} AND task_type NOT IN ('INVENTORY_FLOW_PROBE','INVENTORY_FLOW_HISTORY','INVENTORY_HISTORY_BUILD') AND status IN ('READY','RETRY_WAIT','RUNNING') AND (execution_purpose='BUSINESS' OR (status='READY' AND validation_root_task_id=id)) AND (next_retry_at_utc IS NULL OR next_retry_at_utc<=#{now}) ORDER BY CASE WHEN task_type IN ('RECHECK','REBUILD') THEN 1 WHEN task_role='WAREHOUSE_SYNC' THEN 0 WHEN task_type='BACKFILL' THEN 3 ELSE 2 END,id LIMIT #{limit}") List due(@Param("now") LocalDateTime now, @Param("limit") int limit, @Param("purpose") String purpose); // Started inventory work may resume across midnight; unstarted historical slots remain excluded. // Publication independently requires the planned day and observation day to match for daily samples. - @Select("SELECT t.* FROM ll_selfop_sync_task t JOIN ll_selfop_sync_task r ON r.id=t.business_root_task_id AND r.business_root_task_id=r.id AND r.source_account=t.source_account AND r.execution_purpose='BUSINESS' AND r.validation_root_task_id IS NULL AND r.status<>'DEAD' WHERE t.source_account=#{account} AND t.execution_purpose='BUSINESS' AND t.validation_root_task_id IS NULL AND (r.request_key LIKE 'manual:%' OR (r.requested_by='SELFOP_CONTINUOUS_PLANNER' AND r.request_key LIKE 'continuous:%')) AND t.status IN ('READY','RETRY_WAIT','RUNNING') AND (t.next_retry_at_utc IS NULL OR t.next_retry_at_utc<=#{now}) AND (r.request_key NOT LIKE 'continuous:inventory:%' OR DATE(DATE_ADD(r.planned_at_utc,INTERVAL 8 HOUR))=DATE(DATE_ADD(#{now},INTERVAL 8 HOUR)) OR (r.attempt_count>0 AND r.planned_at_utc<=#{now})) ORDER BY CASE WHEN r.task_role='ACCOUNT_DISCOVERY' AND r.query_until_utc>=DATE_SUB(DATE(DATE_ADD(#{now},INTERVAL 8 HOUR)),INTERVAL 8 HOUR) THEN 0 WHEN r.request_key LIKE 'continuous:inventory:%' THEN 1 WHEN t.task_role='WAREHOUSE_SYNC' THEN 2 WHEN t.task_type IN ('RECHECK','REBUILD') THEN 3 ELSE 4 END,t.query_until_utc DESC,t.id LIMIT #{limit}") + @Select("SELECT t.* FROM ll_selfop_sync_task t JOIN ll_selfop_sync_task r ON r.id=t.business_root_task_id AND r.business_root_task_id=r.id AND r.source_account=t.source_account AND r.execution_purpose='BUSINESS' AND r.validation_root_task_id IS NULL AND r.status<>'DEAD' WHERE t.task_type NOT IN ('INVENTORY_FLOW_HISTORY','INVENTORY_HISTORY_BUILD') AND t.source_account=#{account} AND t.execution_purpose='BUSINESS' AND t.validation_root_task_id IS NULL AND (r.request_key LIKE 'manual:%' OR (r.requested_by='SELFOP_CONTINUOUS_PLANNER' AND r.request_key LIKE 'continuous:%')) AND t.status IN ('READY','RETRY_WAIT','RUNNING') AND (t.next_retry_at_utc IS NULL OR t.next_retry_at_utc<=#{now}) AND (r.request_key NOT LIKE 'continuous:inventory:%' OR DATE(DATE_ADD(r.planned_at_utc,INTERVAL 8 HOUR))=DATE(DATE_ADD(#{now},INTERVAL 8 HOUR)) OR (r.attempt_count>0 AND r.planned_at_utc<=#{now})) ORDER BY CASE WHEN r.task_role='ACCOUNT_DISCOVERY' AND r.query_until_utc>=DATE_SUB(DATE(DATE_ADD(#{now},INTERVAL 8 HOUR)),INTERVAL 8 HOUR) THEN 0 WHEN r.request_key LIKE 'continuous:inventory:%' THEN 1 WHEN t.task_role='WAREHOUSE_SYNC' THEN 2 WHEN t.task_type IN ('RECHECK','REBUILD') THEN 3 ELSE 4 END,t.query_until_utc DESC,t.id LIMIT #{limit}") List dueContinuous(@Param("now") LocalDateTime now,@Param("limit") int limit,@Param("account") String account); @Select("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=#{account} AND apply_state IN ('APPLYING','BLOCKED') AND JSON_CONTAINS(affected_warehouse_codes_json,JSON_QUOTE(#{warehouse}))") long barriers(@Param("account") String account, @Param("warehouse") String warehouse); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java index c78c36466..e65b62dac 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java @@ -15,6 +15,9 @@ public class SelfopAnalyticsProperties { private String collectionMode = "OFF"; /** Explicit account-owner continuous BUSINESS planner/egress mode; off by default. */ private boolean continuousBusinessCollectionEnabled = false; + /** Requires the history tables and both services from the same release. */ + private boolean inventoryHistoryEnabled = false; + private int inventoryHistoryDays = 60; private String environment = "test"; private String exclusiveGrantId; private LocalDateTime exclusiveGrantExpiresAtUtc; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopCollectionPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopCollectionPolicy.java index 5ae1f2270..4d868aa52 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopCollectionPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopCollectionPolicy.java @@ -37,6 +37,17 @@ public final class SelfopCollectionPolicy { if(task==null||!"BUSINESS".equals(task.getExecutionPurpose())||task.getUpstreamTaskId()!=null ||task.getValidationRootTaskId()!=null||!CONTINUOUS_PLANNER.equals(task.getRequestedBy())||task.getScopeHash()==null||!task.getScopeHash().matches("[a-f0-9]{64}"))return false; String key=task.getRequestKey();if(key==null)return false; + if(com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice.TYPE.equals(task.getTaskType())) { + try { + var slice=read(task.getQueryScopeJson(),com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice.class); + return "ACCOUNT_DISCOVERY".equals(task.getTaskRole())&&task.getDiscoveryScopeId()!=null&&task.getScopeId()==null + &&task.getWarehouseCode()==null&&task.getPlannedAtUtc()!=null&&task.getId().equals(task.getBusinessRootTaskId()) + &&task.getScopeHash().equals(SelfopSupport.hash(slice))&&task.getRequestHash().equals(task.getScopeHash()) + &&key.equals("continuous:history:"+task.getPlannedAtUtc().toLocalDate()+":"+task.getScopeHash()) + &&java.time.LocalDateTime.ofInstant(slice.from(),java.time.ZoneOffset.UTC).equals(task.getQueryFromUtc()) + &&java.time.LocalDateTime.ofInstant(slice.until(),java.time.ZoneOffset.UTC).equals(task.getQueryUntilUtc()); + }catch(RuntimeException invalid){return false;} + } if("MASTER_DATA".equals(task.getTaskType())) { if(!"ACCOUNT_DISCOVERY".equals(task.getTaskRole())||task.getDiscoveryScopeId()==null||task.getScopeId()!=null ||task.getWarehouseCode()!=null||task.getPlannedAtUtc()==null||task.getQueryFromUtc()!=null||task.getQueryUntilUtc()!=null)return false; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContext.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContext.java index 00e24df04..e7e175d55 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContext.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContext.java @@ -9,11 +9,13 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverResponses.*; -/** Tokens carry evidence, not authority: all versions and missing dates are reconstructed at asOf. */ +/** Signed evidence fixes exact committed day versions; corrections explicitly revoke their validity. */ @Component public class InventoryTurnoverContext { static final ZoneId ZONE=ZoneId.of("Asia/Shanghai"); - static final String RULE="DAILY_SCHEDULED_FIRST_V1"; + static final String RULE="FLOW_EOD_V1"; + // A service restart expires open pages/exports, which must request a fresh consistent snapshot. + private static final byte[] TOKEN_KEY=new java.security.SecureRandom().generateSeed(32); private final AnalyticsReadMapper analytics; private final InventoryTurnoverReadMapper mapper; private final ObjectMapper json; @@ -28,7 +30,7 @@ public class InventoryTurnoverContext { } public record Evidence(String account,String warehouse,LocalDate end,int days,Instant asOf, Long salesRun,Long inventoryRun,String salesHash,String inventoryHash,boolean rebuild, - String samplesHash,LocalDate accumulationStartedOn) {} + String samplesHash,LocalDate accumulationStartedOn,String rule,List versions) {} record Frozen(Context response,AnalyticsQueryService.Snapshot snapshot) {} Frozen resolve(InventoryTurnoverRequest q) { Instant now=clock.instant(); q.validate(now.atZone(ZONE).toLocalDate()); @@ -37,39 +39,59 @@ public class InventoryTurnoverContext { if(asOf==null||asOf.isAfter(now)||!now.isBefore(asOf.plusSeconds(1800))) throw refresh(); LocalDate end=q.end(asOf.atZone(ZONE).toLocalDate()); if(supplied!=null&&(!Objects.equals(q.sourceAccount(),supplied.account())||!Objects.equals(q.warehouseCode(),supplied.warehouse()) - ||!end.equals(supplied.end())||q.days()!=supplied.days())) throw refresh(); + ||!end.equals(supplied.end())||q.days()!=supplied.days()||!RULE.equals(supplied.rule())||supplied.versions()==null)) throw refresh(); var scopes=analytics.scopes(q.sourceAccount(),q.warehouseCode()); - var sales=version(scopes,"OUTBOUND",asOf); var stock=version(scopes,"INVENTORY",asOf); + var sales=supplied==null?version(scopes,"OUTBOUND",asOf):fixed(supplied.salesRun()); + var stock=supplied==null?version(scopes,"INVENTORY",asOf):fixed(supplied.inventoryRun()); var aq=new AnalyticsQuery(q.sourceAccount(),q.warehouseCode(),null,null,"CUSTOM",end.minusDays(Math.max(30,q.days())-1),end,"DAY",null,1,20, sales==null?0L:sales.id(),stock==null?0L:stock.id(),sales==null?null:sales.scopeHash(),stock==null?null:stock.scopeHash(),asOf); // Staleness is evaluated at queryAsOf, so pagination does not age a fixed snapshot midway. var snapshot=new AnalyticsQueryService(analytics,json,Clock.fixed(asOf,ZoneOffset.UTC),ownerDirectory).context(aq,true,false,false,null); String inventoryHash=scopes.stream().filter(s->"INVENTORY".equals(s.dataFamily())).map(AnalyticsReadRows.Scope::scopeHash).findFirst().orElse(null); var mapping=new TreeMap(); - for(var sample:mapper.samples(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf),end.minusDays(Math.max(30,q.days())-1),end.plusDays(1))) { - if(sample.sampleDate()==null||sample.runId()==null||sample.runId()<=0||sample.sampleDate().isBefore(end.minusDays(Math.max(30,q.days())-1))||sample.sampleDate().isAfter(end) - ||mapping.put(sample.sampleDate(),sample.runId())!=null) throw refresh(); + List selected; + if(supplied==null)selected=mapper.samples(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf),end.minusDays(Math.max(30,q.days())-1),end.plusDays(1)); + else { + if(supplied.versions().size()>366||supplied.versions().stream().anyMatch(v->v==null||v.dayVersionId()==null||v.dayVersionId()<=0))throw refresh(); + selected=supplied.versions().isEmpty()?List.of():mapper.fixedSamples(q.sourceAccount(),q.warehouseCode(),inventoryHash,supplied.versions().stream().map(Sample::dayVersionId).toList()); + if(!selected.stream().map(v->new Sample(v.sampleDate(),v.dayVersionId())).toList().equals(supplied.versions()))throw refresh(); + } + for(var sample:selected) { + if(sample.sampleDate()==null||sample.dayVersionId()==null||sample.dayVersionId()<=0||sample.sampleDate().isBefore(end.minusDays(Math.max(30,q.days())-1))||sample.sampleDate().isAfter(end) + ||mapping.put(sample.sampleDate(),sample.dayVersionId())!=null) throw refresh(); } List samples=new ArrayList<>(); for(LocalDate date=end.minusDays(Math.max(30,q.days())-1);!date.isAfter(end);date=date.plusDays(1)) samples.add(new Sample(date,mapping.get(date))); - var began=mapper.accumulationStartedOn(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf)); + var began=supplied==null?mapper.accumulationStartedOn(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf)):supplied.accumulationStartedOn(); var c=snapshot.context(); var evidence=new Evidence(q.sourceAccount(),q.warehouseCode(),end,q.days(),asOf,c.salesRunId(),c.inventoryRunId(), - c.salesScopeHash(),c.inventoryScopeHash(),c.rebuildRequired(),samplesHash(samples),began); + c.salesScopeHash(),c.inventoryScopeHash(),c.rebuildRequired(),samplesHash(samples),began,RULE,samples.stream().filter(v->v.dayVersionId()!=null).toList()); if(supplied!=null&&!supplied.equals(evidence)) throw refresh(); return new Frozen(new Context(encode(evidence),q.sourceAccount(),q.warehouseCode(),end,q.days(),asOf,c.queryExpiresAt(),c, - c.inventoryObservedFrom(),c.inventoryObservedUntil(),RULE,List.copyOf(samples),samples.stream().filter(s->s.runId()==null).map(Sample::date).toList(),began),snapshot); + c.inventoryObservedFrom(),c.inventoryObservedUntil(),RULE,List.copyOf(samples),samples.stream().filter(s->s.dayVersionId()==null).map(Sample::date).toList(),began),snapshot); } + private AnalyticsReadRows.Run fixed(Long id){if(id==null||id==0)return null;var run=analytics.publishedRun(id);if(run==null)throw refresh();return run;} private AnalyticsReadRows.Run version(List scopes,String family,Instant asOf) { var scope=scopes.stream().filter(s->family.equals(s.dataFamily())).findFirst().orElse(null); return scope==null?null:mapper.versionAt(scope.id(),scope.scopeHash(),utc(asOf)); } private Evidence decode(String token) { - try {var value=json.readValue(Base64.getUrlDecoder().decode(token),Evidence.class);if(value==null)throw refresh();return value;} catch(Exception e){throw refresh();} + try { + if(token.length()>16000)throw refresh();var parts=token.split("\\.",-1);if(parts.length!=2)throw refresh(); + byte[] compressed=Base64.getUrlDecoder().decode(parts[0]); + if(!java.security.MessageDigest.isEqual(sign(compressed),Base64.getUrlDecoder().decode(parts[1])))throw refresh(); + try(var gzip=new java.util.zip.GZIPInputStream(new java.io.ByteArrayInputStream(compressed))){ + byte[] bytes=gzip.readNBytes(65537);if(bytes.length>65536)throw refresh(); + var value=json.readValue(bytes,Evidence.class);if(value==null)throw refresh();return value; + } + } catch(Exception e){throw refresh();} } private String encode(Evidence evidence) { - try{return Base64.getUrlEncoder().withoutPadding().encodeToString(json.writeValueAsBytes(evidence));}catch(Exception e){throw new IllegalStateException("Cannot encode turnover context",e);} + try{var output=new java.io.ByteArrayOutputStream();try(var gzip=new java.util.zip.GZIPOutputStream(output)){gzip.write(json.writeValueAsBytes(evidence));} + byte[] data=output.toByteArray();var b64=Base64.getUrlEncoder().withoutPadding();return b64.encodeToString(data)+"."+b64.encodeToString(sign(data)); + }catch(Exception e){throw new IllegalStateException("Cannot encode turnover context",e);} } + private static byte[] sign(byte[] bytes)throws java.security.GeneralSecurityException{var mac=javax.crypto.Mac.getInstance("HmacSHA256");mac.init(new javax.crypto.spec.SecretKeySpec(TOKEN_KEY,"HmacSHA256"));return mac.doFinal(bytes);} private String samplesHash(List samples) { // Reconstruct the entire ordered calendar at asOf before comparing its digest. // Keep the GET pagination token bounded even for a full year of samples. diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java index cfa530f32..27bc80535 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java @@ -77,11 +77,16 @@ public final class InventoryTurnoverPolicy { if(decimal(r,"frozen")!=null&&decimal(r,"frozen").signum()>0)attention.add("FROZEN_STOCK"); if(decimal(r,"defective")!=null&&decimal(r,"defective").signum()>0)attention.add("DEFECTIVE_STOCK"); if(count(r,"unassessable")>0)attention.add("UNASSESSABLE"); + var sampleReasons=new LinkedHashSet(); + if(!"VALID".equals(sampleStatus)) { + sampleReasons.add(sampleStatus);String historyReasons=text(r,"history_reasons"); + if(historyReasons!=null&&!historyReasons.isBlank())sampleReasons.addAll(Arrays.asList(historyReasons.split(","))); + } String last=text(r,"last_outbound"); return new Row(text(r,"customer_code"),text(r,"customer_name"),owner?null:text(r,"sku_code"),owner?null:text(r,"product_name"),physical,available, total(r,"frozen",owner),total(r,"defective",owner),total(r,"transit",owner),owner?decimal(r,"physical_skus"):physical.value()==null?null:physical.value().signum()>0?BigDecimal.ONE:BigDecimal.ZERO, windows,base.averageDailyOutboundQuantity(),coverage,coverageStatus,average,turnover,sampleStatus,sampleCount,q.days(),List.copyOf(missing), - "VALID".equals(sampleStatus)?List.of():List.of(sampleStatus),last==null?null:LocalDate.parse(last.substring(0,10)),List.copyOf(attention), + List.copyOf(sampleReasons),last==null?null:LocalDate.parse(last.substring(0,10)),List.copyOf(attention), risk(r,"no_outbound",physical,owner,true),risk(r,"slow",physical,owner,q.coverageDaysThreshold()!=null),count(r,"unassessable"), owner?null:decimal(r,"unit_volume"),owner?null:text(r,"dimension_source"),volume(r,"physical"),volumeWindows(r,context.endDate().plusDays(1),q.windows())); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverReadMapper.java index cf4fff1b6..e15f7a368 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverReadMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverReadMapper.java @@ -8,29 +8,22 @@ import org.apache.ibatis.annotations.*; @Mapper public interface InventoryTurnoverReadMapper { - record SampleRun(LocalDate sampleDate,Long runId) {} + record DayVersion(LocalDate sampleDate,Long dayVersionId) {} @Select("SELECT "+AnalyticsReadMapper.RUN_COLUMNS+" FROM ll_selfop_sync_run WHERE scope_id=#{scopeId} " +"AND scope_hash=#{scopeHash} AND status='PUBLISHED' AND run_role='WAREHOUSE_SYNC' AND execution_purpose='BUSINESS' " +"AND published_at_utc<=#{asOf} ORDER BY published_at_utc DESC,id DESC LIMIT 1") AnalyticsReadRows.Run versionAt(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash,@Param("asOf") LocalDateTime asOf); - // The immutable first-daily marker is set only by publication. Recheck its original schedule evidence. - String SAMPLE_WHERE=" r.source_account=#{account} AND r.warehouse_code=#{warehouse} AND r.scope_hash=#{scopeHash,jdbcType=VARCHAR} " - +"AND r.inventory_sample_rule_version='DAILY_SCHEDULED_FIRST_V1' AND r.status='PUBLISHED' " - +"AND r.execution_purpose='BUSINESS' AND r.run_role='WAREHOUSE_SYNC' AND r.coverage_status='COMPLETE' " - +"AND r.published_at_utc<=#{asOf} AND t.execution_purpose='BUSINESS' AND t.task_type='SCHEDULED' " - +"AND t.task_role='WAREHOUSE_SYNC' AND t.scope_hash=r.scope_hash " - +"AND t.source_account=r.source_account AND t.warehouse_code=r.warehouse_code AND r.page_count>=1 AND r.source_total_count>=0 " - +"AND r.source_total_count=r.unique_object_count AND r.fetch_started_at_utc<=r.fetch_finished_at_utc " - +"AND DATE(DATE_ADD(t.planned_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date " - +"AND DATE(DATE_ADD(r.fetch_started_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date " - +"AND DATE(DATE_ADD(r.fetch_finished_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date "; - @Select("SELECT r.inventory_daily_sample_date sample_date,r.id run_id FROM ll_selfop_sync_run r " - +"JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE "+SAMPLE_WHERE - +"AND r.inventory_daily_sample_date>=#{from} AND r.inventory_daily_sample_date<#{until} ORDER BY r.inventory_daily_sample_date") - List samples(@Param("account") String account,@Param("warehouse") String warehouse,@Param("scopeHash") String scopeHash, + String HISTORY_WHERE=" source_account=#{account} AND warehouse_code=#{warehouse} AND scope_hash=#{scopeHash,jdbcType=VARCHAR} " + +"AND rule_version='FLOW_EOD_V1' AND status IN ('PUBLISHED','SUPERSEDED') AND invalidated_at IS NULL "; + @Select("SELECT business_date sample_date,id day_version_id FROM ll_selfop_inventory_day_version WHERE "+HISTORY_WHERE + +"AND head_slot=1 AND published_at<=#{asOf} AND business_date>=#{from} AND business_date<#{until} ORDER BY business_date") + List samples(@Param("account") String account,@Param("warehouse") String warehouse,@Param("scopeHash") String scopeHash, @Param("asOf") LocalDateTime asOf,@Param("from") LocalDate from,@Param("until") LocalDate until); - @Select("SELECT MIN(r.inventory_daily_sample_date) FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE "+SAMPLE_WHERE) + @Select("") + List fixedSamples(@Param("account") String account,@Param("warehouse") String warehouse,@Param("scopeHash") String scopeHash,@Param("ids") List ids); + @Select("SELECT MIN(business_date) FROM ll_selfop_inventory_day_version WHERE "+HISTORY_WHERE+" AND head_slot=1 AND published_at<=#{asOf}") LocalDate accumulationStartedOn(@Param("account") String account,@Param("warehouse") String warehouse,@Param("scopeHash") String scopeHash,@Param("asOf") LocalDateTime asOf); @SelectProvider(type=InventoryTurnoverSql.class,method="page") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverResponses.java index 46e091962..d46cca56b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverResponses.java @@ -23,7 +23,7 @@ public final class InventoryTurnoverResponses { List missingSampleDates,List sampleReasonCodes,LocalDate lastObservedOutboundDate, List attentionReasons,Risk noOutboundInWindow,Risk slowCoverage,int unassessableSkuCount, BigDecimal unitVolumeM3,String dimensionSource,Volume inventoryVolumeM3,List outboundVolumesM3) {} - public record Sample(LocalDate date,Long runId) {} + public record Sample(LocalDate date,Long dayVersionId) {} public record Context(String snapshotContext,String sourceAccount,String warehouseCode,LocalDate endDate, int baseWindowDays,Instant queryAsOf,Instant queryExpiresAt,AnalyticsResponses.Context analyticsContext, Instant inventoryObservedFrom,Instant inventoryObservedUntil,String sampleRuleVersion, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java index ab1e4ffc8..6cc3040d4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java @@ -6,7 +6,7 @@ import java.util.*; /** Every value is bound. Only validated enums select SQL fragments; no source order/remote API reads. */ public final class InventoryTurnoverSql { private InventoryTurnoverSql() {} - public record Day(LocalDate date,LocalDateTime startUtc,int coverage,Long sampleRun) {} + public record Day(LocalDate date,LocalDateTime startUtc,int coverage,Long historyVersionId) {} public record Input(InventoryTurnoverRequest query,long salesRun,long inventoryRun,boolean stockFresh, boolean salesKnown,List days,LocalDateTime queryAsOf) { public LocalDate until(){return days.get(days.size()-1).date().plusDays(1);} @@ -31,7 +31,7 @@ public final class InventoryTurnoverSql { var coverage=SalesQueryCoverage.intervals(frozen.snapshot().sales()); boolean trusted=frozen.snapshot().sourceKnown()&&(publishedHistory||!frozen.response().analyticsContext().rebuildRequired()); var days=frozen.response().dailySamples().stream().map(s->new Day(s.date(),s.date().atStartOfDay().minusHours(8), - !trusted?0:SalesQueryCoverage.complete(coverage,s.date(),s.date().plusDays(1))?1:SalesQueryCoverage.at(coverage,s.date())==null?0:2,s.runId())).toList(); + !trusted?0:SalesQueryCoverage.complete(coverage,s.date(),s.date().plusDays(1))?1:SalesQueryCoverage.at(coverage,s.date())==null?0:2,s.dayVersionId())).toList(); return new Input(q,frozen.response().analyticsContext().salesRunId(),frozen.response().analyticsContext().inventoryRunId(), "VALID".equals(frozen.response().analyticsContext().inventoryStatus()),frozen.snapshot().sourceKnown(),days,LocalDateTime.ofInstant(frozen.response().queryAsOf(),ZoneOffset.UTC)); } @@ -49,15 +49,15 @@ public final class InventoryTurnoverSql { private static String calendar(Input p) { var parts=new ArrayList(); for(int i=0;i(); - for(int i=0;i0 AND w").append(p.query.days()).append("_outbound>0 THEN available*#{p.query.days}/w").append(p.query.days()).append("_outbound END coverage_days,") - .append("CASE WHEN sample_total>0 AND evidence<=#{p.baseFromUtc} THEN w").append(p.query.days()).append("_outbound*#{p.query.days}/sample_total END turnover,") + .append("CASE WHEN sample_total>0 THEN w").append(p.query.days()).append("_outbound*#{p.query.days}/sample_total END turnover,") .append("CASE WHEN physical=0 THEN 0 WHEN physical>0 AND w"+p.query.days()+"_outbound IS NOT NULL THEN CASE WHEN w"+p.query.days()+"_outbound=0 THEN 1 ELSE 0 END END no_outbound,") .append("CASE WHEN physical IS NULL OR available IS NULL OR w").append(p.query.days()).append("_outbound IS NULL THEN 1 ELSE 0 END unassessable FROM sku_base b), sku_result AS (") .append("SELECT b.*").append(InventoryVolumeSql.sku(p)).append(",CASE WHEN #{p.query.coverageDaysThreshold,jdbcType=INTEGER} IS NULL THEN NULL WHEN physical=0 OR available=0 THEN 0 WHEN coverage_days IS NOT NULL AND available>0 THEN CASE WHEN coverage_days>#{p.query.coverageDaysThreshold,jdbcType=INTEGER} THEN 1 ELSE 0 END WHEN available>0 AND w") @@ -127,9 +128,8 @@ public final class InventoryTurnoverSql { // Read only actual qualified samples at the frozen date/run. Missing candidates // remain in sku_zero_counts; absence never creates a zero-valued sample. sql.append(" FROM actual_sku_days GROUP BY customer_code,sku_code), sku_sample_days AS (SELECT st.customer_code,st.sku_code,d.business_date,") - .append("st.physical sample_physical FROM calendar d JOIN stocks st ON st.snapshot_run_id=d.sample_run ") - .append("JOIN fixed_profiles ip ON ip.profile_run_id=d.sample_run AND ip.customer_code=st.customer_code AND ip.sku_code=st.sku_code ") - .append("WHERE ip.quantity_unit='PCS' AND st.physical IS NOT NULL), sku_sample_counts AS (SELECT customer_code,sku_code,") + .append("st.physical sample_physical FROM calendar d JOIN history st ON st.day_version_id=d.sample_run ") + .append("WHERE st.physical IS NOT NULL), sku_sample_counts AS (SELECT customer_code,sku_code,") .append("COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count,") // Keep the qualified sum exact; do not divide again by a rounded daily mean. .append("CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) END sample_total,") @@ -146,11 +146,11 @@ public final class InventoryTurnoverSql { // Qualified date membership supports the full selected window without numeric bit overflow. return "GROUP_CONCAT(CASE WHEN sample_physical IS NOT NULL THEN CAST(business_date AS CHAR) END ORDER BY business_date SEPARATOR ',')"; } - private static String pageMissingDates() { + private static String pageMissingDates(Input p) { // Only the selected page expands dates. Numeric qualification and filtering // already used every candidate and every frozen sample before pagination. return ", page_missing AS (SELECT r.*, (SELECT GROUP_CONCAT(CAST(business_date AS CHAR) ORDER BY business_date SEPARATOR ',') FROM calendar d " - +"WHERE d.business_date>=CAST(#{p.baseFrom} AS DATE) AND LOCATE(CAST(d.business_date AS CHAR),COALESCE(r.sample_mask,''))=0) missing_dates FROM page_result r)"; + +"WHERE d.business_date>=CAST(#{p.baseFrom} AS DATE) AND LOCATE(CAST(d.business_date AS CHAR),COALESCE(r.sample_mask,''))=0) missing_dates, (SELECT GROUP_CONCAT(DISTINCT h.reason SEPARATOR ',') FROM history h JOIN calendar d ON d.sample_run=h.day_version_id WHERE h.customer_code=r.customer_code "+("SKU".equals(p.query.group())?"AND h.sku_code=r.sku_code ":"")+"AND d.business_date>=CAST(#{p.baseFrom} AS DATE)) history_reasons FROM page_result r)"; } private static String windowAggregates(Input p) { StringBuilder sql=new StringBuilder(); @@ -243,7 +243,7 @@ public final class InventoryTurnoverSql { } public static String page(Map args) { Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()); - return cte(p)+(owner?ownerCte(p):"")+", page_result AS (SELECT * FROM "+(owner?"owner_result":"sku_result")+filter(p,owner)+order(p,owner)+" LIMIT #{p.query.pageSize} OFFSET #{p.query.offset})"+pageMissingDates()+" SELECT * FROM page_missing"+order(p,owner); + return cte(p)+(owner?ownerCte(p):"")+", page_result AS (SELECT * FROM "+(owner?"owner_result":"sku_result")+filter(p,owner)+order(p,owner)+" LIMIT #{p.query.pageSize} OFFSET #{p.query.offset})"+pageMissingDates(p)+" SELECT * FROM page_missing"+order(p,owner); } public static String count(Map args) { Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()); @@ -270,7 +270,7 @@ public final class InventoryTurnoverSql { public static String report(Map args) { Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()),overview=p.query.overview(); String source=owner?"owner_result":"sku_result"; - List fields=new ArrayList<>(List.of("customer_code","physical","available","frozen","defective","transit","sample_count","average_physical","missing_dates","last_outbound","turnover","unassessable")); + List fields=new ArrayList<>(List.of("customer_code","physical","available","frozen","defective","transit","sample_count","average_physical","missing_dates","history_reasons","last_outbound","turnover","unassessable")); for(int n:p.query().windows())for(String suffix:List.of("outbound","observed","complete","partial"))fields.add("w"+n+"_"+suffix); fields.addAll(InventoryVolumeSql.fields(p)); if(owner) { @@ -281,7 +281,7 @@ public final class InventoryTurnoverSql { // Stock charts share owner totals, independently of list filters and pagination. String sql=cte(p)+(owner?ownerCte(p):overview?", owner_totals AS ("+totals(p,"customer_code",true)+" FROM sku_result GROUP BY customer_code) ":"") +", page_result AS (SELECT r.*,ROW_NUMBER() OVER ("+order(p,owner)+") ordinal FROM "+source+" r"+filter(p,owner)+order(p,owner)+" LIMIT #{p.query.pageSize} OFFSET #{p.query.offset})"; - sql+=pageMissingDates(); + sql+=pageMissingDates(p); if(overview)sql+=", summary_result AS ("+totals(p,null,false)+" FROM sku_result), top_owners AS (SELECT customer_code,physical,ROW_NUMBER() OVER (ORDER BY physical DESC,customer_code) ordinal FROM owner_totals WHERE physical IS NOT NULL ORDER BY physical DESC,customer_code LIMIT 10)" +", top_volume_owners AS (SELECT t.*,ROW_NUMBER() OVER (ORDER BY physical_volume DESC,customer_code) ordinal FROM owner_totals t WHERE physical_volume IS NOT NULL ORDER BY physical_volume DESC,customer_code LIMIT 10)"; // Statement-local only: share intermediate relations across report branches; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java index e8af72117..0916e0227 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java @@ -40,7 +40,18 @@ public class SelfopEgressService { if(business!=null){verifyBusinessTask(task,root);String limit=business.limit(now);if(limit!=null){stopBusiness(root,business,limit,now);throw reject(limit);}} verify(task,run,root,account,now); boolean flowOperation=operation==Operation.INVENTORY_FLOW; - if(flowOperation!="INVENTORY_FLOW_PROBE".equals(task.getTaskType()) + boolean history=com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice.TYPE.equals(task.getTaskType()); + if(history){ + if(!flowOperation||!config.isInventoryHistoryEnabled()||!SelfopCollectionPolicy.continuousBusinessRoot(task) + ||!SelfopCollectionPolicy.continuousBusiness(config)||pageSize!=1000)throw reject("EGRESS_HISTORY_INVALID"); + try { + var slice=SelfopSupport.read(task.getQueryScopeJson(),com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice.class); + var checkpoint=SelfopSupport.read(run.getProgressSnapshotJson(),com.fasterxml.jackson.databind.JsonNode.class); + int page=checkpoint.path("nextPage").asInt(); + if(!slice.warehouse().equals(warehouseCode)||!config.getWarehouses().containsKey(warehouseCode) + ||!slice.requestHash(page).equals(requestHash))throw reject("EGRESS_HISTORY_INVALID"); + }catch(EgressRejectedException e){throw e;}catch(RuntimeException invalid){throw reject("EGRESS_HISTORY_INVALID");} + }else if(flowOperation!="INVENTORY_FLOW_PROBE".equals(task.getTaskType()) ||flowOperation&&(!"test".equals(config.getEnvironment())||!"BUSINESS".equals(task.getExecutionPurpose()) ||pageSize!=1000||!config.getWarehouses().containsKey(warehouseCode) ||business==null||business.http!=0||!Objects.equals(task.getRequestHash(),requestHash)))throw reject("EGRESS_FLOW_PROBE_INVALID"); @@ -79,6 +90,7 @@ public class SelfopEgressService { var account=account(c.sourceAccount());var budget=budget(account,now,c.executionPurpose());var pending=pendingForAuthorization(budget,c,request.operation()); var root=root(parseId(c.rootTaskId()),c.executionPurpose(),c.sourceAccount()); var task=mapper.lockTask(pending.taskId);var run=mapper.lockRun(pending.runId); + if(com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice.TYPE.equals(task==null?null:task.getTaskType())&&!config.isInventoryHistoryEnabled())throw reject("EGRESS_HISTORY_DISABLED"); if(hasBusinessSession(root)){ var b=business(root);verifyBusinessTask(task,root); // Explicit root stops remain independent of request accounting and short permit expiry. diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionMapper.java index b072b737b..aee96cb42 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionMapper.java @@ -19,6 +19,7 @@ public interface SalesRetentionMapper { AND r.superseded_at_utc IS NOT NULL AND r.superseded_at_utc <= #{cutoff} AND r.inventory_daily_sample_date IS NULL AND r.inventory_sample_rule_version IS NULL AND r.id != #{current} AND (#{previous} IS NULL OR r.id != #{previous}) + AND NOT EXISTS (SELECT 1 FROM ll_selfop_inventory_day_version h WHERE h.sales_run_id=r.id OR h.anchor_run_id=r.id) AND (EXISTS (SELECT 1 FROM ll_selfop_sku_daily_sales d WHERE d.sales_run_id=r.id) OR EXISTS (SELECT 1 FROM ll_selfop_warehouse_daily_sales w WHERE w.sales_run_id=r.id) OR EXISTS (SELECT 1 FROM ll_selfop_sku_profile p WHERE p.profile_run_id=r.id)) @@ -32,10 +33,11 @@ public interface SalesRetentionMapper { List eligible(@Param("scopeId") Long scopeId,@Param("current") Long current, @Param("previous") Long previous,@Param("cutoff") LocalDateTime cutoff); @Select(""" - SELECT COUNT(*) FROM ll_selfop_sync_run input JOIN ll_selfop_sync_task t ON t.id=input.task_id + SELECT (SELECT COUNT(*) FROM ll_selfop_sync_run input JOIN ll_selfop_sync_task t ON t.id=input.task_id WHERE t.source_account=#{account} AND t.warehouse_code=#{warehouse} AND (t.status NOT IN ('SUCCEEDED','SAMPLE_DONE','CANCELLED') OR t.apply_state IN ('APPLYING','BLOCKED')) - AND (input.base_published_run_id=#{id} OR input.reference_sales_run_id=#{id}) + AND (input.base_published_run_id=#{id} OR input.reference_sales_run_id=#{id})) + + (SELECT COUNT(*) FROM ll_selfop_inventory_day_version h WHERE h.sales_run_id=#{id} OR h.anchor_run_id=#{id}) """) long activeReferences(@Param("account") String account,@Param("warehouse") String warehouse,@Param("id") Long id); @Delete("DELETE FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{id} LIMIT #{batchSize}") diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 28019b23a..6990e6844 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -3715,7 +3715,7 @@ CREATE TABLE ll_selfop_sync_task ( observation_baseline BIGINT NULL COMMENT '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测', request_key VARCHAR(128) NOT NULL COMMENT '共享发现包含切片/计划槽位;分仓任务包含仓库、任务族及切片或计划槽位;人工附请求幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求类型、查询范围、范围摘要的指纹;同键不同请求拒绝', - task_type VARCHAR(24) NOT NULL COMMENT 'SCHEDULED、MANUAL_RETRY、BACKFILL、RECHECK、REBUILD、VALIDATION;验证不由自动计划创建', + task_type VARCHAR(24) NOT NULL COMMENT '计划、补采、复核、重建与验证;INVENTORY_FLOW_HISTORY为流水片,INVENTORY_HISTORY_BUILD为本地历史还原', planned_at_utc DATETIME(3) NULL COMMENT '计划时点;人工请求可空', query_from_utc DATETIME(3) NULL COMMENT '出库查询区间UTC下界,库存不填', query_until_utc DATETIME(3) NULL COMMENT '出库查询区间UTC上界,不含端点,库存不填', @@ -3999,3 +3999,86 @@ CREATE TABLE ll_selfop_product ( UNIQUE KEY uk_selfop_product_version (run_id,source_account,customer_code,sku_code), KEY idx_selfop_product_lookup (source_account,customer_code,sku_code,run_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星商品资料版本'; + +-- 历史库存基线;执行前必须取得指定环境的建表授权。 +CREATE TABLE IF NOT EXISTS ll_selfop_inventory_flow ( + id BIGINT NOT NULL COMMENT '应用生成的流水记录标识', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码', + customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码', + sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码', + source_flow_id VARCHAR(64) NOT NULL COMMENT '来源流水字符串标识', + product_quality INT NOT NULL COMMENT '来源品质,零正品一次品,其他未知', + cell_no VARCHAR(128) NULL COMMENT '来源库位编码', + inner_batch_no VARCHAR(128) NULL COMMENT '来源内部批次号', + bill_no VARCHAR(128) NULL COMMENT '来源业务单号', + source_no VARCHAR(128) NULL COMMENT '来源关联单号', + bill_type VARCHAR(32) NULL COMMENT '来源业务类型,未验证类型不得参与正式还原', + book_qty DECIMAL(20,6) NULL COMMENT '来源记账增减量,未知为空', + book_change_qty_raw VARCHAR(64) NULL COMMENT '来源带符号变动量原文', + delta_quantity DECIMAL(20,6) NULL COMMENT '解析后的有符号实物变动量', + close_quantity DECIMAL(20,6) NULL COMMENT '对应库位批次品质链的结存', + source_create_time VARCHAR(64) NULL COMMENT '来源北京时间原文', + event_at_utc DATETIME(3) NULL COMMENT '流水发生UTC时刻,无法解析时为空', + business_date DATE NULL COMMENT '北京时间业务日期', + quantity_unit VARCHAR(16) NULL COMMENT '已确认数量单位,未知为空', + content_hash CHAR(64) NOT NULL COMMENT '当前事实内容摘要', + revision_no BIGINT NOT NULL COMMENT '同来源流水单调增加的修订号', + stage_id BIGINT NOT NULL COMMENT '当前事实对应不可变暂存证据', + first_run_id BIGINT NOT NULL COMMENT '首次观察执行尝试', + last_run_id BIGINT NOT NULL COMMENT '最近确认执行尝试', + last_observed_at DATETIME(3) NOT NULL COMMENT '最近观察UTC时刻', + validation_status VARCHAR(16) NOT NULL COMMENT '字段资格,有效或未知', + validation_reason VARCHAR(64) NULL COMMENT '数量或身份待核验原因', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_flow_source (source_account,warehouse_code,source_flow_id), + KEY idx_selfop_flow_sku (source_account,warehouse_code,customer_code,sku_code,event_at_utc), + KEY idx_selfop_flow_day (source_account,warehouse_code,business_date), + CONSTRAINT chk_selfop_flow_revision CHECK (revision_no > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='自营历史库存流水最新事实,旧版本保留于暂存证据'; + +CREATE TABLE IF NOT EXISTS ll_selfop_inventory_day_version ( + id BIGINT NOT NULL COMMENT '不可变历史日版本标识', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码', + business_date DATE NOT NULL COMMENT '北京时间日末所属日期', + scope_hash CHAR(64) NOT NULL COMMENT '业务范围摘要', + rule_version VARCHAR(32) NOT NULL COMMENT '日末库存还原规则版本', + build_run_id BIGINT NOT NULL COMMENT '历史重建执行尝试', + publication_group_id BIGINT NOT NULL COMMENT '必须一起切换的发布组标识', + sales_run_id BIGINT NULL COMMENT '构建时固定的销量证据版本', + anchor_run_id BIGINT NOT NULL COMMENT '固定的独立库存锚点版本', + input_hash CHAR(64) NOT NULL COMMENT '冻结流水修订与覆盖及锚点输入摘要', + evidence_json JSON NOT NULL COMMENT '固定输入清单及不可变证据引用', + status VARCHAR(16) NOT NULL COMMENT '构建中、就绪、已发布、已替代或失败', + head_slot TINYINT NULL COMMENT '当前日头为一,非当前版本为空', + valid_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '合格商品数', + unknown_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '待核验商品数', + created_at DATETIME(3) NOT NULL COMMENT '版本创建UTC时刻', + published_at DATETIME(3) NULL COMMENT '发布组提交UTC时刻', + superseded_at DATETIME(3) NULL COMMENT '被新组替代的UTC时刻', + invalidated_at DATETIME(3) NULL COMMENT '已确认输入失效的UTC时刻', + invalidation_reason VARCHAR(64) NULL COMMENT '旧版本失效原因', + rebuild_run_id BIGINT NULL COMMENT '处理失效版本的重建尝试', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_history_head (source_account,warehouse_code,business_date,head_slot), + UNIQUE KEY uk_selfop_history_build_day (build_run_id,business_date), + KEY idx_selfop_history_group (publication_group_id,status), + CONSTRAINT chk_selfop_history_head CHECK (head_slot IS NULL OR (head_slot=1 AND status='PUBLISHED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日库存不可变版本及原子发布日头'; + +CREATE TABLE IF NOT EXISTS ll_selfop_inventory_daily ( + day_version_id BIGINT NOT NULL COMMENT '所属历史日版本', + customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码', + sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码', + product_quality INT NOT NULL COMMENT '来源库存品质', + physical_quantity DECIMAL(20,6) NULL COMMENT '日末实物库存,未知为空,已确认零为零', + quality_status VARCHAR(16) NOT NULL COMMENT '日末资格,有效或未知', + quality_reason VARCHAR(64) NULL COMMENT '缺覆盖或锚点等未知原因', + anchor_reference BIGINT NULL COMMENT '独立库存锚点版本', + evidence_hash CHAR(64) NOT NULL COMMENT '该日计算证据摘要', + source VARCHAR(32) NOT NULL COMMENT '库存来源标记,流水还原', + PRIMARY KEY (day_version_id,customer_code,sku_code,product_quality), + KEY idx_selfop_daily_identity (customer_code,sku_code,day_version_id), + CONSTRAINT chk_selfop_daily_quantity CHECK ((quality_status='VALID' AND physical_quantity IS NOT NULL AND physical_quantity>=0) OR (quality_status='UNKNOWN' AND physical_quantity IS NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日末分货主商品品质实物库存'; diff --git a/lastleg-service/src/main/resources/db/selfop-inventory-history.sql b/lastleg-service/src/main/resources/db/selfop-inventory-history.sql new file mode 100644 index 000000000..9b3904491 --- /dev/null +++ b/lastleg-service/src/main/resources/db/selfop-inventory-history.sql @@ -0,0 +1,82 @@ +-- 历史库存基线;执行前必须取得指定环境的建表授权。 +CREATE TABLE IF NOT EXISTS ll_selfop_inventory_flow ( + id BIGINT NOT NULL COMMENT '应用生成的流水记录标识', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码', + customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码', + sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码', + source_flow_id VARCHAR(64) NOT NULL COMMENT '来源流水字符串标识', + product_quality INT NOT NULL COMMENT '来源品质,零正品一次品,其他未知', + cell_no VARCHAR(128) NULL COMMENT '来源库位编码', + inner_batch_no VARCHAR(128) NULL COMMENT '来源内部批次号', + bill_no VARCHAR(128) NULL COMMENT '来源业务单号', + source_no VARCHAR(128) NULL COMMENT '来源关联单号', + bill_type VARCHAR(32) NULL COMMENT '来源业务类型,未验证类型不得参与正式还原', + book_qty DECIMAL(20,6) NULL COMMENT '来源记账增减量,未知为空', + book_change_qty_raw VARCHAR(64) NULL COMMENT '来源带符号变动量原文', + delta_quantity DECIMAL(20,6) NULL COMMENT '解析后的有符号实物变动量', + close_quantity DECIMAL(20,6) NULL COMMENT '对应库位批次品质链的结存', + source_create_time VARCHAR(64) NULL COMMENT '来源北京时间原文', + event_at_utc DATETIME(3) NULL COMMENT '流水发生UTC时刻,无法解析时为空', + business_date DATE NULL COMMENT '北京时间业务日期', + quantity_unit VARCHAR(16) NULL COMMENT '已确认数量单位,未知为空', + content_hash CHAR(64) NOT NULL COMMENT '当前事实内容摘要', + revision_no BIGINT NOT NULL COMMENT '同来源流水单调增加的修订号', + stage_id BIGINT NOT NULL COMMENT '当前事实对应不可变暂存证据', + first_run_id BIGINT NOT NULL COMMENT '首次观察执行尝试', + last_run_id BIGINT NOT NULL COMMENT '最近确认执行尝试', + last_observed_at DATETIME(3) NOT NULL COMMENT '最近观察UTC时刻', + validation_status VARCHAR(16) NOT NULL COMMENT '字段资格,有效或未知', + validation_reason VARCHAR(64) NULL COMMENT '数量或身份待核验原因', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_flow_source (source_account,warehouse_code,source_flow_id), + KEY idx_selfop_flow_sku (source_account,warehouse_code,customer_code,sku_code,event_at_utc), + KEY idx_selfop_flow_day (source_account,warehouse_code,business_date), + CONSTRAINT chk_selfop_flow_revision CHECK (revision_no > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='自营历史库存流水最新事实,旧版本保留于暂存证据'; + +CREATE TABLE IF NOT EXISTS ll_selfop_inventory_day_version ( + id BIGINT NOT NULL COMMENT '不可变历史日版本标识', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码', + business_date DATE NOT NULL COMMENT '北京时间日末所属日期', + scope_hash CHAR(64) NOT NULL COMMENT '业务范围摘要', + rule_version VARCHAR(32) NOT NULL COMMENT '日末库存还原规则版本', + build_run_id BIGINT NOT NULL COMMENT '历史重建执行尝试', + publication_group_id BIGINT NOT NULL COMMENT '必须一起切换的发布组标识', + sales_run_id BIGINT NULL COMMENT '构建时固定的销量证据版本', + anchor_run_id BIGINT NOT NULL COMMENT '固定的独立库存锚点版本', + input_hash CHAR(64) NOT NULL COMMENT '冻结流水修订与覆盖及锚点输入摘要', + evidence_json JSON NOT NULL COMMENT '固定输入清单及不可变证据引用', + status VARCHAR(16) NOT NULL COMMENT '构建中、就绪、已发布、已替代或失败', + head_slot TINYINT NULL COMMENT '当前日头为一,非当前版本为空', + valid_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '合格商品数', + unknown_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '待核验商品数', + created_at DATETIME(3) NOT NULL COMMENT '版本创建UTC时刻', + published_at DATETIME(3) NULL COMMENT '发布组提交UTC时刻', + superseded_at DATETIME(3) NULL COMMENT '被新组替代的UTC时刻', + invalidated_at DATETIME(3) NULL COMMENT '已确认输入失效的UTC时刻', + invalidation_reason VARCHAR(64) NULL COMMENT '旧版本失效原因', + rebuild_run_id BIGINT NULL COMMENT '处理失效版本的重建尝试', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_history_head (source_account,warehouse_code,business_date,head_slot), + UNIQUE KEY uk_selfop_history_build_day (build_run_id,business_date), + KEY idx_selfop_history_group (publication_group_id,status), + CONSTRAINT chk_selfop_history_head CHECK (head_slot IS NULL OR (head_slot=1 AND status='PUBLISHED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日库存不可变版本及原子发布日头'; + +CREATE TABLE IF NOT EXISTS ll_selfop_inventory_daily ( + day_version_id BIGINT NOT NULL COMMENT '所属历史日版本', + customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码', + sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码', + product_quality INT NOT NULL COMMENT '来源库存品质', + physical_quantity DECIMAL(20,6) NULL COMMENT '日末实物库存,未知为空,已确认零为零', + quality_status VARCHAR(16) NOT NULL COMMENT '日末资格,有效或未知', + quality_reason VARCHAR(64) NULL COMMENT '缺覆盖或锚点等未知原因', + anchor_reference BIGINT NULL COMMENT '独立库存锚点版本', + evidence_hash CHAR(64) NOT NULL COMMENT '该日计算证据摘要', + source VARCHAR(32) NOT NULL COMMENT '库存来源标记,流水还原', + PRIMARY KEY (day_version_id,customer_code,sku_code,product_quality), + KEY idx_selfop_daily_identity (customer_code,sku_code,day_version_id), + CONSTRAINT chk_selfop_daily_quantity CHECK ((quality_status='VALID' AND physical_quantity IS NOT NULL AND physical_quantity>=0) OR (quality_status='UNKNOWN' AND physical_quantity IS NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日末分货主商品品质实物库存'; diff --git a/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml index d2481ebf9..3910fdeec 100644 --- a/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml @@ -6,12 +6,12 @@ info: 仅PLATFORM、tenantId=0、非代登录及platform:self-operated-analytics:list权限。 仓库由服务端受控配置校验;不触发采集,不读取来源订单,不调用领星。 所选窗口为截至endDate的1~366个完整北京时间自然日,并提供7/15/30天辅助对比;当前库存独立固定版本。 - 每次请求使用REPEATABLE_READ。snapshotContext为服务端重建验证的版本与日代表证据, - 不作为授权凭据;账号、仓库、截止日、基准、queryAsOf、版本对、范围摘要、 - 重建状态及全部max(30,baseWindowDays)日映射(含缺失日期)逐项重建比较;无效或过期要求整体刷新。 - 历史代表须同范围、正式计划采集、已发布且DAILY_SCHEDULED_FIRST_V1合格标记。 + 每次请求使用REPEATABLE_READ,一致性读固定销量、当前库存及历史日版本映射。 + snapshotContext保存签名压缩的准确版本清单与缺失日期,不作为授权凭据;后续页按准确ID校验 + 账号、仓库、范围及失效标记,不用墙钟时刻重新推导日头。服务重启、校正失效、规则变更要求整体刷新。 + 历史分母只使用FLOW_EOD_V1日末全品质实物库存,以完整流水及独立库存锚点还原;不与旧计划样本混算。 上下文有效期最多30分钟;客户端不能直接指定runId。SKU/关注分页不改变概览范围。 - 历史销量完整性不依赖当前库存新鲜度;缺样本不补零、不缩短周转分母。 + 历史销量完整性不依赖当前库存新鲜度;缺日保持未知、不缩短周转分母。sampleReasonCodes包含流水或独立库存对账失败原因。dailySamples.dayVersionId表示历史日版本标识,非原始库存run。 security: [{bearerAuth: []}] paths: /api/platform/v1/last-leg/self-operated-analytics/inventory-turnover: @@ -143,17 +143,17 @@ components: analyticsContext: {$ref: './self-operated-analytics-v1.yaml#/components/schemas/Context'} inventoryObservedFrom: {type: string, format: date-time, nullable: true} inventoryObservedUntil: {type: string, format: date-time, nullable: true} - sampleRuleVersion: {type: string, enum: [DAILY_SCHEDULED_FIRST_V1]} + sampleRuleVersion: {type: string, enum: [FLOW_EOD_V1]} dailySamples: type: array minItems: 30 maxItems: 366 items: type: object - required: [date, runId] + required: [date, dayVersionId] properties: date: {type: string, format: date} - runId: {type: string, nullable: true, description: 内部版本证据,不作为用户可见编号} + dayVersionId: {type: string, nullable: true, description: 内部版本证据,不作为用户可见编号} missingSampleDates: {type: array, items: {type: string, format: date}} accumulationStartedOn: {type: string, format: date, nullable: true} Row: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryFlowSliceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryFlowSliceTest.java new file mode 100644 index 000000000..71f582e11 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryFlowSliceTest.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.selfoperated.analytics.history; + +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport; +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.Test; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +class InventoryFlowSliceTest { + private final InventoryFlowSlice slice=new InventoryFlowSlice("W",LocalDateTime.parse("2026-10-14T00:00:00"),LocalDateTime.parse("2026-10-15T00:00:00"),1000); + JsonNode row(String id,String at){return SelfopSupport.read(SelfopSupport.json(Map.of("id",id,"customerCode","C","whCode","W","productSku","SKU","createTime",at)),JsonNode.class);} + JsonNode page(int current,int total,List rows){return SelfopSupport.read(SelfopSupport.json(Map.of("current",current,"size",1000,"total",total,"pages",total==0?0:(total-1)/1000+1,"records",rows)),JsonNode.class);} + @Test void emptyRangeAndInclusiveMidnightArePreserved(){ + assertEquals(0,slice.validate(page(1,0,List.of()),1,null,null).total()); + assertEquals(1,slice.validate(page(1,1,List.of(row("1","2026-10-15 00:00:00"))),1,null,null).rows().size()); + assertEquals(Instant.parse("2026-10-14T16:00:01Z"),slice.until()); + } + @Test void totalDriftDuplicatesAndOrderingNeverProveCoverage(){ + assertThrows(IllegalStateException.class,()->slice.validate(page(1,1,List.of(row("1","2026-10-14 12:00:00"))),1,2L,null)); + assertThrows(IllegalStateException.class,()->slice.validate(page(1,2,List.of(row("1","2026-10-14 12:00:00"),row("1","2026-10-14 12:00:00"))),1,null,null)); + assertThrows(IllegalStateException.class,()->slice.validate(page(1,1,List.of(row("1","2026-10-14 12:00:00"))),1,null,LocalDateTime.parse("2026-10-14T13:00:00"))); + assertThrows(IllegalStateException.class,()->slice.validate(page(1,2,List.of(row("1","2026-10-14 12:00:00"))),1,null,null)); + assertThrows(IllegalStateException.class,()->slice.validate(page(1,1,List.of(row("1","2026-10-15 00:00:01"))),1,null,null)); + } + @Test void fixedPageHashCannotChangeWarehouseWindowOrPage(){ + assertEquals(slice.requestHash(1),SelfopSupport.hash(slice.request(1))); + assertNotEquals(slice.requestHash(1),slice.requestHash(2)); + assertThrows(IllegalArgumentException.class,()->new InventoryFlowSlice("W",slice.start(),slice.end().plusDays(1),1000)); + assertThrows(IllegalArgumentException.class,()->new InventoryFlowSlice("W",slice.start(),slice.end(),100)); + } + @Test void acceptsInternalLongStringButRejectsFractionalOrOverflowTotals(){ + var response=(com.fasterxml.jackson.databind.node.ObjectNode)page(1,0,List.of()); + response.put("total","0"); + assertEquals(0,slice.validate(response,1,null,null).total()); + for(String invalid:List.of("0.0","-1","9223372036854775808","1e3","")) { + response.put("total",invalid); + assertThrows(IllegalStateException.class,()->slice.validate(response,1,null,null)); + } + response.put("total",0.5); + assertThrows(IllegalStateException.class,()->slice.validate(response,1,null,null)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryCalculatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryCalculatorTest.java new file mode 100644 index 000000000..c3921f5d4 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryCalculatorTest.java @@ -0,0 +1,85 @@ +package com.baiyue.lastleg.selfoperated.analytics.history; + +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import static org.assertj.core.api.Assertions.assertThat; +import static com.baiyue.lastleg.selfoperated.analytics.history.InventoryHistoryCalculator.*; + +class InventoryHistoryCalculatorTest { + private static final Sku SKU=new Sku("OWNER","SKU"); + private static final Key GOOD=new Key(SKU,0), BAD=new Key(SKU,1); + private static final LocalDate DAY=LocalDate.of(2026,10,14); + private static Instant at(String local) { return LocalDateTime.parse(local).toInstant(ZoneOffset.ofHours(8)); } + private static BigDecimal n(String n) { return new BigDecimal(n); } + private static Anchor anchor(Key k,String qty) { return new Anchor(k,n(qty),"PCS",at("2026-10-15T08:00:00"),at("2026-10-15T08:00:01"),100); } + private static Flow flow(String id,Key key,String type,String delta,String close,String time) { + return new Flow(id,key,"CELL","BATCH",type,n(delta),n(delta),n(close),at(time)); + } + private static Input input(List rows,List anchors) { + return new Input(DAY,DAY.plusDays(1),Set.of(SKU),rows,anchors, + List.of(new Interval(at("2026-10-14T00:00:00"),at("2026-10-15T08:00:02")))); + } + @Test void reversesTailAndAssignsMidnightToNewDay() { + var r=reconstruct(input(List.of(flow("f",GOOD,"DO","-1","144","2026-10-15T00:00:00")),List.of(anchor(GOOD,"144")))); + assertThat(r.get(0).quantity()).isEqualByComparingTo("145"); + } + @Test void missingTailIsUnknownEvenWhenYesterdayIsComplete() { + var i=input(List.of(),List.of(anchor(GOOD,"144"))); + var r=reconstruct(new Input(i.from(),i.until(),i.candidates(),i.flows(),i.anchors(),List.of(new Interval(at("2026-10-14T00:00:00"),at("2026-10-15T00:00:00"))))); + assertThat(r.get(0).reason()).isEqualTo("FLOW_COVERAGE_GAP");assertThat(r.get(0).quantity()).isNull(); + } + @Test void keepsInactiveBatchInSkuAnchor() { + var r=reconstruct(input(List.of(flow("f",GOOD,"DO","-1","10","2026-10-15T01:00:00")),List.of(anchor(GOOD,"100")))); + assertThat(r.get(0).quantity()).isEqualByComparingTo("101"); + } + @Test void sameIdAtOverlappingBoundaryIsCountedOnce() { + var f=flow("f",GOOD,"DO","-1","9","2026-10-15T01:00:00"); + assertThat(reconstruct(input(List.of(f,f),List.of(anchor(GOOD,"9")))).get(0).quantity()).isEqualByComparingTo("10"); + } + @Test void sameIdDifferentIdentityRevokesBoth() { + var f=flow("f",GOOD,"DO","-1","9","2026-10-15T01:00:00"); + var g=flow("f",BAD,"IMO","1","1","2026-10-15T01:00:00"); + assertThat(reconstruct(input(List.of(f,g),List.of(anchor(GOOD,"9"),anchor(BAD,"1"))))).allMatch(d->"FLOW_ID_CONFLICT".equals(d.reason())); + } + @Test void qualityConversionPreservesTotalWithoutDroppingEitherSide() { + var r=reconstruct(input(List.of(flow("a",GOOD,"IMO","-3","7","2026-10-15T01:00:00"),flow("b",BAD,"IMO","3","3","2026-10-15T01:00:00")),List.of(anchor(GOOD,"7"),anchor(BAD,"3")))); + assertThat(r.stream().map(Daily::quantity).reduce(BigDecimal.ZERO,BigDecimal::add)).isEqualByComparingTo("10"); + assertThat(r.get(0).quantity()).isEqualByComparingTo("10");assertThat(r.get(1).quantity()).isZero(); + } + @Test void absentAnchorNeverBecomesZero() { + assertThat(reconstruct(input(List.of(),List.of())).get(0).reason()).isEqualTo("ANCHOR_MISSING"); + } + @Test void explicitZeroWithCompleteCoverageIsValid() { + var d=reconstruct(input(List.of(),List.of(anchor(GOOD,"0")))).get(0); + assertThat(d.status()).isEqualTo("VALID");assertThat(d.quantity()).isZero(); + } + @Test void unverifiedTypeIsNotIgnored() { + assertThat(reconstruct(input(List.of(flow("f",GOOD,"BO","1","1","2026-10-15T01:00:00")),List.of(anchor(GOOD,"1")))).get(0).reason()).isEqualTo("FLOW_TYPE_UNVERIFIED"); + } + @Test void eventInAnchorObservationIsAmbiguous() { + assertThat(reconstruct(input(List.of(flow("f",GOOD,"DO","-1","9","2026-10-15T08:00:00")),List.of(anchor(GOOD,"9")))).get(0).reason()).isEqualTo("ANCHOR_TIME_AMBIGUOUS"); + } + @Test void sameSecondOrderUsesBalancesNotIdOrResponseOrder() { + var rows=List.of(flow("1",GOOD,"DO","-1","37","2026-10-15T01:00:00"),flow("9",GOOD,"DO","-1","38","2026-10-15T01:00:00")); + assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"37")))).get(0).quantity()).isEqualByComparingTo("39"); + } + @Test void brokenChainDoesNotPublish() { + var rows=List.of(flow("1",GOOD,"DO","-1","38","2026-10-15T01:00:00"),flow("2",GOOD,"DO","-1","35","2026-10-15T02:00:00")); + assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"35")))).get(0).reason()).isEqualTo("CHAIN_DISCONTINUITY"); + } + @Test void laterAnchorRevisionChangesEarlierDay() { + var a=reconstruct(input(List.of(flow("f",GOOD,"DO","-1","100","2026-10-15T01:00:00")),List.of(anchor(GOOD,"100")))); + var b=reconstruct(input(List.of(flow("f",GOOD,"DO","-2","100","2026-10-15T01:00:00")),List.of(anchor(GOOD,"100")))); + assertThat(a.get(0).quantity()).isEqualByComparingTo("101");assertThat(b.get(0).quantity()).isEqualByComparingTo("102"); + } + @Test void negativeSkuBalanceCannotHideBehindPositiveBatchClosing() { + assertThat(reconstruct(input(List.of(flow("f",GOOD,"PA","5","5","2026-10-15T01:00:00")),List.of(anchor(GOOD,"3")))).get(0).reason()).isEqualTo("NEGATIVE_RECONSTRUCTED_STOCK"); + } + @Test void mergingCoverageDoesNotBridgeGaps() { + var t=Instant.EPOCH; + assertThat(covered(List.of(new Interval(t,t.plusSeconds(2)),new Interval(t.plusSeconds(1),t.plusSeconds(4))),t,t.plusSeconds(4))).isTrue(); + assertThat(covered(List.of(new Interval(t,t.plusSeconds(1)),new Interval(t.plusSeconds(2),t.plusSeconds(4))),t,t.plusSeconds(4))).isFalse(); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryPipelineTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryPipelineTest.java new file mode 100644 index 000000000..7c6b77e7b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryPipelineTest.java @@ -0,0 +1,126 @@ +package com.baiyue.lastleg.selfoperated.analytics.history; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.selfoperated.analytics.client.RestLingxingAnalyticsClient; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baiyue.lastleg.selfoperated.analytics.sync.SelfopLeaseService; +import com.fasterxml.jackson.databind.JsonNode; +import org.apache.ibatis.datasource.unpooled.UnpooledDataSource; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import org.junit.jupiter.api.*; +import java.nio.file.*; +import java.time.*; +import java.math.BigDecimal; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** Actual mapper + SQL + transaction pipeline, isolated H2; provider is a bounded fixture. */ +public class InventoryHistoryPipelineTest { + JdbcTemplate jdbc;TransactionTemplate tx;SelfopAnalyticsProperties config;SqlSessionTemplate session; + SelfopSyncTaskMapper tasks;SelfopSyncRunMapper runs;SelfopSyncStageMapper stages;SelfopCoordinationMapper locks; + SelfopLeaseService leases;RestLingxingAnalyticsClient client;InventoryHistoryVersions versions;InventoryFlowFacts facts; + InventoryHistoryBuilder builder;InventoryFlowCollector collector; + static final LocalDate DAY=LocalDate.of(2026,10,14); + @BeforeEach void setup()throws Exception { + var ds=new UnpooledDataSource("org.h2.Driver","jdbc:h2:mem:history_pipeline_"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa",""); + jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds)); + String ddl=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for(String table:List.of("ll_selfop_outbound_discovery_scope","ll_selfop_sync_scope","ll_selfop_sync_task","ll_selfop_sync_run","ll_selfop_sync_stage","ll_selfop_inventory_snapshot","ll_selfop_sku_daily_sales","ll_selfop_sku_profile","ll_selfop_inventory_flow","ll_selfop_inventory_day_version","ll_selfop_inventory_daily")) { + var m=java.util.regex.Pattern.compile("CREATE TABLE (?:IF NOT EXISTS )?"+table+" \\([\\s\\S]*?\\) ENGINE[^;]+;").matcher(ddl);assertTrue(m.find(),table); + jdbc.execute(m.group().replaceAll("\\) ENGINE[^;]+;",");").replace(" JSON "," VARCHAR(1000000) ")); + } + jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR '"+getClass().getName()+".extract'");jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR '"+getClass().getName()+".unquote'"); + var c=new MybatisConfiguration();c.setMapUnderscoreToCamelCase(true); + for(var mapper:List.of(SelfopSyncTaskMapper.class,SelfopSyncRunMapper.class,SelfopSyncStageMapper.class,SelfopCoordinationMapper.class,SelfopSyncScopeMapper.class,SelfopOutboundDiscoveryScopeMapper.class))c.addMapper(mapper); + var factory=new MybatisSqlSessionFactoryBean();factory.setConfiguration(c);factory.setDataSource(ds);session=new SqlSessionTemplate(Objects.requireNonNull(factory.getObject())); + tasks=session.getMapper(SelfopSyncTaskMapper.class);runs=session.getMapper(SelfopSyncRunMapper.class);stages=session.getMapper(SelfopSyncStageMapper.class); + var real=session.getMapper(SelfopCoordinationMapper.class);locks=mock(SelfopCoordinationMapper.class,org.mockito.AdditionalAnswers.delegatesTo(real)); + doReturn(List.of()).when(locks).dueContinuous(any(),anyInt(),anyString()); + config=new SelfopAnalyticsProperties();config.setSourceAccount("demo");config.setCollectionMode("BUSINESS");config.setContinuousBusinessCollectionEnabled(true);config.setInventoryHistoryEnabled(true);config.setSourceQueryTimezone("Asia/Shanghai");config.setQuantityUnit("PCS");config.getWarehouses().put("W",new SelfopAnalyticsProperties.Warehouse()); + leases=new SelfopLeaseService(locks,session.getMapper(SelfopSyncScopeMapper.class),session.getMapper(SelfopOutboundDiscoveryScopeMapper.class),config); + client=mock(RestLingxingAnalyticsClient.class);versions=new InventoryHistoryVersions(jdbc,tx);facts=new InventoryFlowFacts(jdbc,tx,versions); + collector=newCollector();builder=new InventoryHistoryBuilder(jdbc,tx,versions,tasks,runs,stages); + jdbc.update("INSERT INTO ll_selfop_outbound_discovery_scope (id,created_at,updated_at,source_account,fence_token,request_budget,time_budget_seconds,min_request_interval_ms) VALUES(1,?,?,?,0,100,120,1200)",utcNow(),utcNow(),"demo"); + jdbc.update("INSERT INTO ll_selfop_sync_scope (id,created_at,updated_at,source_account,warehouse_code,data_family,scope_json,scope_hash,business_timezone,source_timezone,schedule_cron,stale_after_seconds,fence_token,fact_revision,published_fact_revision,rebuild_required,review_request_budget,review_time_budget_seconds) VALUES(2,?,?,'demo','W','INVENTORY','{}',?,'Asia/Shanghai','Asia/Shanghai','0 0 8 * * *',93600,0,0,0,0,100,120)",utcNow(),utcNow(),"b".repeat(64)); + } + @AfterEach void close(){jdbc.execute("SHUTDOWN");} + public static String extract(String value,String path){return json(read(value,JsonNode.class).path(path.substring(2)));} + public static String unquote(String value){return read(value,JsonNode.class).asText();} + InventoryFlowCollector newCollector(){return new InventoryFlowCollector(config,tasks,runs,stages,locks,leases,client,jdbc,tx);} + InventoryFlowSlice slice(){return new InventoryFlowSlice("W",DAY.atStartOfDay(),DAY.plusDays(1).atStartOfDay(),1000);} + JsonNode flow(String id,String delta,String close){return read("{\"id\":\""+id+"\",\"customerCode\":\"C\",\"whCode\":\"W\",\"productSku\":\"SKU\",\"productQuality\":0,\"cellNo\":\"CELL\",\"innerBatchNo\":\"BATCH\",\"billType\":\"DO\",\"bookQty\":"+delta+",\"bookChangQty\":\""+delta+"\",\"closeQty\":"+close+",\"createTime\":\"2026-10-14 12:00:00\"}",JsonNode.class);} + JsonNode page(int current,long total,List rows){return read(json(Map.of("current",current,"size",1000,"total",total,"pages",total==0?0:(total-1)/1000+1,"records",rows)),JsonNode.class);} + void respond(JsonNode page){when(client.inventoryFlowPage(any(),eq("W"),any(),any(),anyInt(),eq(1000))).thenReturn(page);} + void collect(InventoryFlowSlice slice,LocalDate round,JsonNode page){collector.plan(1,slice,round);respond(page);collector.page();while(facts.mergeNext("demo")){} } + @Test void collectionReplaysDoNotDoubleCountAndRevisionInvalidatesAtomically() { + collect(slice(),DAY,page(1,1,List.of(flow("F","-1","9")))); + assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_flow",Integer.class)); + assertEquals("FACTS_APPLIED",jdbc.queryForObject("SELECT status FROM ll_selfop_sync_run",String.class)); + collect(slice(),DAY.plusDays(1),page(1,1,List.of(flow("F","-1","9")))); + assertEquals(1,jdbc.queryForObject("SELECT revision_no FROM ll_selfop_inventory_flow",Integer.class)); + var build=new InventoryHistoryVersions.Build("demo","W","b".repeat(64),50,51,52,null,versions.factHash("demo","W"),"{}",Map.of()); + var candidate=versions.create(build,DAY);versions.ready(candidate,0);versions.publish(build,List.of(candidate)); + collect(slice(),DAY.plusDays(2),page(1,1,List.of(flow("F","-2","8")))); + assertEquals(2,jdbc.queryForObject("SELECT revision_no FROM ll_selfop_inventory_flow",Integer.class)); + assertNotNull(jdbc.queryForObject("SELECT invalidated_at FROM ll_selfop_inventory_day_version",Object.class)); + assertEquals(3,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_stage",Integer.class)); + } + @Test void changedIdentityIsQuarantinedAndNeverMoved() { + collect(slice(),DAY,page(1,1,List.of(flow("F","-1","9")))); + var changed=(com.fasterxml.jackson.databind.node.ObjectNode)flow("F","-1","9");changed.put("customerCode","OTHER"); + collect(slice(),DAY.plusDays(1),page(1,1,List.of(changed))); + assertEquals("C",jdbc.queryForObject("SELECT customer_code FROM ll_selfop_inventory_flow",String.class)); + assertEquals("FLOW_IDENTITY_CONFLICT",jdbc.queryForObject("SELECT validation_reason FROM ll_selfop_inventory_flow",String.class)); + } + @Test void missingPreviouslySeenFlowRemainsUnknownInsteadOfBeingDeleted() { + collect(slice(),DAY,page(1,1,List.of(flow("F","-1","9")))); + collect(slice(),DAY.plusDays(1),page(1,0,List.of())); + assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_flow",Integer.class)); + assertEquals("SOURCE_FLOW_MISSING",jdbc.queryForObject("SELECT validation_reason FROM ll_selfop_inventory_flow",String.class)); + } + @Test void restartedCollectorStartsNewAttemptAndDoesNotMixPages() { + collector.plan(1,slice(),DAY);var rows=new ArrayList();for(int i=0;i<1000;i++)rows.add(flow("F"+i,"-1","9")); + respond(page(1,1001,rows));collector.page();assertEquals(1000,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_stage",Integer.class)); + collector=newCollector();respond(page(1,1,List.of(flow("NEW","-1","9"))));collector.page(); + assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_run",Integer.class)); + assertEquals("ABORTED",jdbc.queryForObject("SELECT status FROM ll_selfop_sync_run WHERE attempt_no=1",String.class)); + while(facts.mergeNext("demo")){}assertEquals("NEW",jdbc.queryForObject("SELECT source_flow_id FROM ll_selfop_inventory_flow",String.class)); + } + @Test void malformedPageSplitsFixedWindowWithoutGrantingCoverage() { + collector.plan(1,slice(),DAY);respond(page(1,2,List.of(flow("F","-1","9"))));collector.page(); + assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE status='DEAD'",Integer.class)); + assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE status='READY'",Integer.class)); + assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_run WHERE coverage_status='COMPLETE'",Integer.class)); + assertFalse(facts.mergeNext("demo")); + } + @Test void independentAnchorMismatchPublishesUnknownAndNeverInventsQuantity() { + stock(100,DAY,20);stock(101,DAY.plusDays(1),9); + collect(slice(),DAY,page(1,1,List.of(flow("F","-1","9")))); + collect(new InventoryFlowSlice("W",DAY.plusDays(1).atStartOfDay(),DAY.plusDays(1).atTime(8,0,2),1000),DAY,page(1,0,List.of())); + builder.build("demo","W",30); + assertEquals("INDEPENDENT_ANCHOR_MISMATCH",jdbc.queryForObject("SELECT quality_reason FROM ll_selfop_inventory_daily",String.class)); + assertNull(jdbc.queryForObject("SELECT physical_quantity FROM ll_selfop_inventory_daily",BigDecimal.class)); + } + @Test void localReconstructionPublishesDayAndKeepsRawSnapshotsUnchanged() { + stock(100,DAY,10);stock(101,DAY.plusDays(1),9); + collect(slice(),DAY,page(1,1,List.of(flow("F","-1","9")))); + var tail=new InventoryFlowSlice("W",DAY.plusDays(1).atStartOfDay(),DAY.plusDays(1).atTime(8,0,2),1000); + collect(tail,DAY,page(1,0,List.of()));builder.build("demo","W",30); + assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_day_version WHERE head_slot=1",Integer.class)); + assertEquals(0,new BigDecimal("9").compareTo(jdbc.queryForObject("SELECT physical_quantity FROM ll_selfop_inventory_daily",BigDecimal.class))); + assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_snapshot",Integer.class)); + builder.build("demo","W",30);assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_day_version",Integer.class)); + } + void stock(long id,LocalDate day,int quantity) { + var now=day.atTime(0,0);var r=new SelfopSyncRun();r.setId(id);r.setTaskId(id);r.setAttemptNo(1);r.setSourceAccount("demo");r.setWarehouseCode("W");r.setScopeId(2L);r.setRunRole("WAREHOUSE_SYNC");r.setExecutionPurpose("BUSINESS");r.setScopeHash("b".repeat(64));r.setScopeSnapshotJson("{}");r.setFenceToken(0L);r.setStatus("PUBLISHED");r.setLastPhase("PUBLISHED");r.setCoverageStatus("COMPLETE");r.setSourceTotalCount(1L);r.setUniqueObjectCount(1L);r.setPageCount(1);r.setCreatedAt(now);r.setUpdatedAt(now);r.setFetchStartedAtUtc(now);r.setFetchFinishedAtUtc(now.plusSeconds(1));r.setPublishedAtUtc(now.plusSeconds(2));runs.insert(r); + jdbc.update("INSERT INTO ll_selfop_inventory_snapshot(id,created_at,updated_at,snapshot_run_id,source_account,customer_code,warehouse_code,sku_code,product_quality,physical_quantity,observed_at_utc,quality_status) VALUES(?,?,?,?,'demo','C','W','SKU',0,?,?,'VALID')",id,now,now,id,quantity,now.plusSeconds(1)); + jdbc.update("INSERT INTO ll_selfop_sku_profile(id,created_at,updated_at,profile_run_id,source_account,customer_code,warehouse_code,sku_code,quantity_unit,evidence_type,first_seen_at_utc,last_seen_run_id) VALUES(?,?,?,?,'demo','C','W','SKU','PCS','INVENTORY_OBSERVATION',?,?)",id,now,now,id,now,id); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryVersionsTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryVersionsTest.java new file mode 100644 index 000000000..fbf3a77a2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryVersionsTest.java @@ -0,0 +1,87 @@ +package com.baiyue.lastleg.selfoperated.analytics.history; + +import org.junit.jupiter.api.*; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.core.io.ClassPathResource; +import java.nio.charset.StandardCharsets; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static com.baiyue.lastleg.selfoperated.analytics.history.InventoryHistoryCalculator.*; +import static com.baiyue.lastleg.selfoperated.analytics.history.InventoryHistoryVersions.*; + +class InventoryHistoryVersionsTest { + JdbcTemplate jdbc;TransactionTemplate tx;InventoryHistoryVersions versions; + final LocalDate day=LocalDate.of(2026,10,1); + @BeforeEach void setup() throws Exception { + var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa",""); + jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds));versions=new InventoryHistoryVersions(jdbc,tx); + jdbc.execute("CREATE TABLE ll_selfop_sync_scope(id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),data_family VARCHAR(32),scope_hash VARCHAR(64))"); + jdbc.update("INSERT INTO ll_selfop_sync_scope VALUES(1,'a','w','INVENTORY','scope')"); + String ddl=new ClassPathResource("db/selfop-inventory-history.sql").getContentAsString(StandardCharsets.UTF_8) + .replaceAll("(?m)^--.*$","").replaceAll("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin",""); + for(String statement:ddl.split(";")) if(!statement.isBlank())jdbc.execute(statement); + } + Build build(long id,Map heads) {return new Build("a","w","scope",id,id,10,20L,versions.factHash("a","w"),"{}",heads);} + Candidate candidate(Build b,LocalDate date,String quantity) { + Candidate c=versions.create(b,date); + versions.append(c,List.of(new Daily(date,new Key(new Sku("o","s"),0),new BigDecimal(quantity),"VALID",null,10)),"evidence"); + versions.ready(c,1);return c; + } + @Test void unpublishedRowsAreInvisibleAndGroupSwitchesAllDates() { + var b=build(1,Map.of());var first=candidate(b,day,"100");var second=candidate(b,day.plusDays(1),"100"); + assertThat(versions.heads("a","w",day,day.plusDays(2))).isEmpty(); + versions.publish(b,List.of(first,second)); + var old=versions.heads("a","w",day,day.plusDays(2)); + var next=build(2,old);var n1=candidate(next,day,"110");var n2=candidate(next,day.plusDays(1),"110"); + assertThat(versions.heads("a","w",day,day.plusDays(2))).isEqualTo(old); + versions.publish(next,List.of(n1,n2)); + assertThat(versions.heads("a","w",day,day.plusDays(2))).isEqualTo(Map.of(day,n1.id(),day.plusDays(1),n2.id())); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_daily",Integer.class)).isEqualTo(4); + } + @Test void failureAfterFirstHeadChangeRollsBackWholeGroup() { + var b=build(1,Map.of());var c1=candidate(b,day,"100");var c2=candidate(b,day.plusDays(1),"100");versions.publish(b,List.of(c1,c2)); + var old=versions.heads("a","w",day,day.plusDays(2));var next=build(2,old); + var n1=candidate(next,day,"110");var n2=candidate(next,day.plusDays(1),"110"); + jdbc.update("UPDATE ll_selfop_inventory_day_version SET invalidated_at=CURRENT_TIMESTAMP WHERE id=?",n2.id()); + assertThatThrownBy(()->versions.publish(next,List.of(n1,n2))).hasMessage("HISTORY_CANDIDATE_CHANGED"); + assertThat(versions.heads("a","w",day,day.plusDays(2))).isEqualTo(old); + assertThat(jdbc.queryForObject("SELECT status FROM ll_selfop_inventory_day_version WHERE id=?",String.class,n1.id())).isEqualTo("READY"); + } + @Test void partialGroupAndIncompleteSkuSetsCannotPublish() { + var b=build(1,Map.of());var c1=candidate(b,day,"100");candidate(b,day.plusDays(1),"100"); + assertThatThrownBy(()->versions.publish(b,List.of(c1))).hasMessage("HISTORY_GROUP_INCOMPLETE"); + var c=versions.create(build(2,Map.of()),day.plusDays(2)); + assertThatThrownBy(()->versions.ready(c,1)).hasMessage("HISTORY_CANDIDATE_INCOMPLETE"); + } + @Test void unknownRowsAreRetainedAndPublishedVersionsCannotBeAppended() { + var b=build(1,Map.of());var c=versions.create(b,day); + versions.append(c,List.of(new Daily(day,new Key(new Sku("o","s"),0),null,"UNKNOWN","ANCHOR_MISSING",0)),"hash");versions.ready(c,1);versions.publish(b,List.of(c)); + assertThat(jdbc.queryForObject("SELECT unknown_sku_count FROM ll_selfop_inventory_day_version WHERE id=?",Long.class,c.id())).isEqualTo(1); + assertThatThrownBy(()->versions.append(c,List.of(),"hash")).hasMessage("HISTORY_CANDIDATE_IMMUTABLE"); + } + @Test void publicationIsIdempotentAfterCommit() { + var b=build(1,Map.of());var c=candidate(b,day,"100");versions.publish(b,List.of(c));versions.publish(b,List.of(c)); + assertThat(versions.heads("a","w",day,day.plusDays(1))).hasSize(1); + } + @Test void aNewHeadRejectsStaleBuild() { + var stale=build(1,Map.of());var c=candidate(stale,day,"100"); + var winner=build(2,Map.of());var c2=candidate(winner,day,"110");versions.publish(winner,List.of(c2)); + assertThatThrownBy(()->versions.publish(stale,List.of(c))).hasMessage("HISTORY_HEAD_CHANGED"); + } + @Test void changedWarehouseScopeRejectsFrozenCandidate() { + var b=build(1,Map.of());var c=candidate(b,day,"100"); + jdbc.update("UPDATE ll_selfop_sync_scope SET scope_hash='new-scope'"); + assertThatThrownBy(()->versions.publish(b,List.of(c))).hasMessage("HISTORY_SCOPE_CHANGED"); + assertThat(versions.heads("a","w",day,day.plusDays(1))).isEmpty(); + } + @Test void knownInvaliditySurvivesFailedRebuild() { + var b=build(1,Map.of());var c=candidate(b,day,"100");versions.publish(b,List.of(c)); + tx.executeWithoutResult(s->{versions.lockWarehouse("a","w");versions.invalidate("a","w",2,"FLOW_REVISION");}); + assertThat(jdbc.queryForObject("SELECT invalidation_reason FROM ll_selfop_inventory_day_version WHERE id=?",String.class,c.id())).isEqualTo("FLOW_REVISION"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java index 1f511b19d..931aca795 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java @@ -26,7 +26,8 @@ class InventoryTurnoverContextTest { when(analytics.publishedRun(run.id())).thenReturn(run); when(mapper.versionAt(eq(run.scopeId()),eq(run.scopeHash()),any())).thenReturn(run); } - when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of(new InventoryTurnoverReadMapper.SampleRun(END,100L))); + when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of(new InventoryTurnoverReadMapper.DayVersion(END,100L))); + when(mapper.fixedSamples(eq("demo"),eq("W"),eq(IH),any())).thenReturn(List.of(new InventoryTurnoverReadMapper.DayVersion(END,100L))); when(mapper.accumulationStartedOn(eq("demo"),eq("W"),eq(IH),any())).thenReturn(END); } @Test void sameContextFreezesAllThirtyDatesAndMissingSamples() { @@ -40,10 +41,12 @@ class InventoryTurnoverContextTest { assertEquals(first,second.response()); assertEquals(InventoryTurnoverSql.input(request(first.snapshotContext()),contexts.resolve(request(first.snapshotContext()))).days(), InventoryTurnoverSql.input(page2,second).days()); - verify(mapper,times(4)).samples("demo","W",IH,utc(NOW),END.minusDays(29),END.plusDays(1)); - // A subsequently published sample cannot appear in the old asOf query. If reconstruction - // ever differs, reject the context instead of mixing old charts and new rows. - when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of()); + verify(mapper,times(1)).samples("demo","W",IH,utc(NOW),END.minusDays(29),END.plusDays(1)); + // Even a late commit bearing an older wall-clock timestamp cannot change the exact mapping. + when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of(new InventoryTurnoverReadMapper.DayVersion(END,101L))); + assertEquals(first,contexts.resolve(request(first.snapshotContext())).response()); + // Explicit invalidation revokes the old evidence rather than silently switching its version. + when(mapper.fixedSamples(eq("demo"),eq("W"),eq(IH),any())).thenReturn(List.of()); assertThrows(BizException.class,()->contexts.resolve(request(first.snapshotContext()))); } @Test void fullYearContextRemainsSmallAndReconstructsEveryMissingDate() { @@ -53,14 +56,12 @@ class InventoryTurnoverContextTest { assertTrue(first.snapshotContext().length()<2048); var next=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,20,false,first.snapshotContext(),false); assertEquals(first,at(NOW.plusSeconds(60)).resolve(next).response()); - when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of()); + when(mapper.fixedSamples(eq("demo"),eq("W"),eq(IH),any())).thenReturn(List.of()); assertThrows(BizException.class,()->contexts.resolve(next)); } @Test void rejectsTamperedVersionsMappingWarehouseAndExpiry() throws Exception { String token=contexts.resolve(request(null)).response().snapshotContext(); - var evidence=json.readTree(Base64.getUrlDecoder().decode(token)); - ((com.fasterxml.jackson.databind.node.ObjectNode)evidence).put("salesRun",999L); - String tampered=Base64.getUrlEncoder().encodeToString(json.writeValueAsBytes(evidence)); + String tampered=(token.charAt(0)=='A'?"B":"A")+token.substring(1); assertThrows(BizException.class,()->contexts.resolve(request(tampered))); var another=new InventoryTurnoverRequest("demo","OTHER",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token,false); assertThrows(BizException.class,()->contexts.resolve(another)); @@ -81,7 +82,7 @@ class InventoryTurnoverContextTest { } @Test void rejectsDuplicateRepresentativesAndScopeDrift() { when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of( - new InventoryTurnoverReadMapper.SampleRun(END,100L),new InventoryTurnoverReadMapper.SampleRun(END,101L))); + new InventoryTurnoverReadMapper.DayVersion(END,100L),new InventoryTurnoverReadMapper.DayVersion(END,101L))); assertThrows(BizException.class,()->contexts.resolve(request(null))); when(analytics.publishedRun(11L)).thenReturn(run(11,2,IH)); assertThrows(BizException.class,()->contexts.resolve(request(null))); @@ -141,6 +142,17 @@ class InventoryTurnoverContextTest { var q=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,2,20,false,first.context().snapshotContext(),false); var next=service.query(q);assertNull(next.summary());assertNull(next.charts());verify(mapper).report(any());verify(mapper,never()).chartWindows(any()); } + @Test void fullYearContextKeepsExactVersionsAndBoundedSignedToken() { + var versions=java.util.stream.IntStream.range(0,366).mapToObj(i->new InventoryTurnoverReadMapper.DayVersion(END.minusDays(365-i),2110000000000000000L+i*100000003L)).toList(); + when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(versions); + when(mapper.fixedSamples(eq("demo"),eq("W"),eq(IH),any())).thenReturn(versions); + var first=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,null,1,20,true,null,false); + var frozen=contexts.resolve(first).response(); + assertEquals(366,frozen.dailySamples().size()); + assertTrue(frozen.snapshotContext().length()<7500); + var next=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,null,2,20,false,frozen.snapshotContext(),false); + assertEquals(frozen,contexts.resolve(next).response()); + } private InventoryTurnoverContext at(Instant instant){return new InventoryTurnoverContext(analytics,mapper,json,Clock.fixed(instant,ZoneOffset.UTC),mock(SelfopOwnerDirectory.class));} static InventoryTurnoverRequest request(String token){return new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token,false);} private static LocalDateTime utc(Instant instant){return LocalDateTime.ofInstant(instant,ZoneOffset.UTC);} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java index 60c841e25..e5e0040bb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java @@ -24,6 +24,8 @@ class InventoryTurnoverSqlTest { execute("CREATE TABLE ll_selfop_sku_profile(profile_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,product_name VARCHAR,quantity_unit VARCHAR,evidence_type VARCHAR,evidence_run_id BIGINT,earliest_evidence_at_utc TIMESTAMP)"); execute("CREATE TABLE ll_selfop_sku_daily_sales(sales_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,business_date DATE,outbound_quantity DECIMAL(20,6),quantity_unit VARCHAR)"); execute("CREATE TABLE ll_selfop_inventory_snapshot(snapshot_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,product_quality INT,physical_quantity DECIMAL(20,6),available_quantity DECIMAL(20,6),frozen_quantity DECIMAL(20,6),transit_quantity DECIMAL(20,6),quality_status VARCHAR,quality_reason VARCHAR)"); + execute("CREATE TABLE ll_selfop_inventory_day_version(id BIGINT PRIMARY KEY,source_account VARCHAR,warehouse_code VARCHAR,rule_version VARCHAR,invalidated_at TIMESTAMP)"); + execute("CREATE TABLE ll_selfop_inventory_daily(day_version_id BIGINT,customer_code VARCHAR,sku_code VARCHAR,product_quality INT,physical_quantity DECIMAL(20,6),quality_status VARCHAR,quality_reason VARCHAR)"); } @AfterEach void close() throws Exception {db.close();} private void master() throws Exception { @@ -285,7 +287,7 @@ class InventoryTurnoverSqlTest { } @Test void invalidHistoricalUnitOnlyInvalidatesItsSampleAndNeverActualOwnerSales() throws Exception { seed("A","SKU",30); - execute("UPDATE ll_selfop_sku_profile SET quantity_unit='UNKNOWN' WHERE profile_run_id=100"); + execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=NULL,quality_status='UNKNOWN',quality_reason='UNIT_UNKNOWN' WHERE day_version_id=100"); var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true); var row=query("page",p).get(0); decimal("300",row.get("w30_outbound"));decimal("60",row.get("coverage_days")); @@ -301,7 +303,7 @@ class InventoryTurnoverSqlTest { var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true); var row=query("page",p).get(0); decimal("0",row.get("turnover"));decimal("1",row.get("no_outbound"));assertNull(row.get("coverage_days")); - execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=0 WHERE snapshot_run_id>=100"); + execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=0 WHERE day_version_id>=100"); row=query("page",p).get(0);decimal("0",row.get("average_physical"));assertNull(row.get("turnover")); } @Test void emptyWarehouseChartDistinguishesCoveredZeroFromMissingEvidence() throws Exception { @@ -409,7 +411,7 @@ class InventoryTurnoverSqlTest { @Test void sparseSamplesKeepExactDateComplementAndFullOwnerDenominator() throws Exception { seed("A","ONE",30);seed("A","TWO",30); // Both edge bits and a middle bit, with different missing members per day. - execute("DELETE FROM ll_selfop_inventory_snapshot WHERE warehouse_code='W' AND ((sku_code='ONE' AND snapshot_run_id IN (100,114)) OR (sku_code='TWO' AND snapshot_run_id=129))"); + execute("DELETE FROM ll_selfop_inventory_daily WHERE ((sku_code='ONE' AND day_version_id IN (100,114)) OR (sku_code='TWO' AND day_version_id=129))"); for(int n:List.of(7,15,30)) { var q=new InventoryTurnoverRequest("demo","W",END,n,"OWNER",null,null,"ALL",null,null,null,1,20,true,null,false); var p=input(q,30,30,true); @@ -434,7 +436,8 @@ class InventoryTurnoverSqlTest { long first=9_007_199_254_740_993L; for(int i=0;i<30;i++) { execute("UPDATE ll_selfop_sku_profile SET profile_run_id="+(first+i)+" WHERE profile_run_id="+(100+i)); - execute("UPDATE ll_selfop_inventory_snapshot SET snapshot_run_id="+(first+i)+",physical_quantity=CASE WHEN product_quality=0 THEN "+((i+1)*10)+" ELSE 0 END WHERE snapshot_run_id="+(100+i)); + execute("UPDATE ll_selfop_inventory_day_version SET id="+(first+i)+" WHERE id="+(100+i)); + execute("UPDATE ll_selfop_inventory_daily SET day_version_id="+(first+i)+",physical_quantity=CASE WHEN product_quality=0 THEN "+((i+1)*10)+" ELSE 0 END WHERE day_version_id="+(100+i)); } List days=new ArrayList<>(); for(int i=0;i<30;i++){var date=END.minusDays(29-i);days.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),1,first+i));} @@ -475,7 +478,7 @@ class InventoryTurnoverSqlTest { for(String group:List.of("SKU","OWNER")) { var p=input(request(group,"ALL","PHYSICAL_DESC",null,null,1,20),0,30,true); var unknown=reportRows(p).get(0);assertNull(unknown.get("w30_outbound"));assertNull(unknown.get("w30_observed")); - var partialDays=p.days().stream().map(d->new InventoryTurnoverSql.Day(d.date(),d.startUtc(),2,d.sampleRun())).toList(); + var partialDays=p.days().stream().map(d->new InventoryTurnoverSql.Day(d.date(),d.startUtc(),2,d.historyVersionId())).toList(); var partial=reportRows(new InventoryTurnoverSql.Input(p.query(),11,22,true,true,partialDays,LocalDateTime.of(2026,10,13,0,0))).get(0); assertNull(partial.get("w30_outbound"));decimal("0",partial.get("w30_observed"));decimal("30",partial.get("w30_partial")); var complete=reportRows(input(p.query(),30,30,true)).get(0); @@ -541,16 +544,16 @@ class InventoryTurnoverSqlTest { } @Test void presentInvalidSamplesNeverQualifyAndOwnerZeroNeedsEverySku() throws Exception { seed("A","ONE",30);seed("A","TWO",30); - String key=" WHERE snapshot_run_id=100 AND sku_code='TWO' AND product_quality=0"; - for(String mutation:List.of("physical_quantity=NULL","physical_quantity=-1","product_quality=9","quality_status='UNKNOWN',quality_reason='UNCONFIRMED'","profile")) { - if("profile".equals(mutation))execute("UPDATE ll_selfop_sku_profile SET quantity_unit='UNKNOWN' WHERE profile_run_id=100 AND sku_code='TWO'"); - else execute("UPDATE ll_selfop_inventory_snapshot SET "+mutation+key); + String key=" WHERE day_version_id=100 AND sku_code='TWO' AND product_quality=0"; + for(String mutation:List.of("physical_quantity=NULL","physical_quantity=-1","product_quality=9","quality_status='UNKNOWN',quality_reason='UNCONFIRMED'")) { + if("profile".equals(mutation))execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=NULL,quality_status='UNKNOWN',quality_reason='UNIT_UNKNOWN' WHERE day_version_id=100 AND sku_code='TWO'"); + else execute("UPDATE ll_selfop_inventory_daily SET "+mutation+key); var owner=reportRows(input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)).get(0); decimal("29",owner.get("sample_count"));assertNull(owner.get("turnover"));assertEquals(END.minusDays(29).toString(),owner.get("missing_dates")); - execute("DELETE FROM ll_selfop_inventory_snapshot WHERE snapshot_run_id=100 AND sku_code='TWO'");stock(100,"A","TWO",0,450,450);stock(100,"A","TWO",1,50,0); + execute("DELETE FROM ll_selfop_inventory_daily WHERE day_version_id=100 AND sku_code='TWO'");stock(100,"A","TWO",0,450,450);stock(100,"A","TWO",1,50,0); execute("UPDATE ll_selfop_sku_profile SET quantity_unit='PCS' WHERE profile_run_id=100 AND sku_code='TWO'"); } - execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=0 WHERE snapshot_run_id>=100"); + execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=0 WHERE day_version_id>=100"); var owner=reportRows(input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)).get(0); decimal("30",owner.get("sample_count"));decimal("0",owner.get("average_physical"));assertNull(owner.get("turnover")); } @@ -568,6 +571,7 @@ class InventoryTurnoverSqlTest { String amount="99999999999999.123456";var quantity=new BigDecimal(amount); execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity="+amount+" WHERE warehouse_code='W'"); execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE WHEN product_quality=0 THEN "+amount+" ELSE 0 END"); + execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=CASE WHEN product_quality=0 THEN "+amount+" ELSE 0 END"); var report=query("report",input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)); var row=payload(report.stream().filter(r->"ROW".equals(r.get("section"))).findFirst().orElseThrow()); decimal(quantity.multiply(BigDecimal.valueOf(120)).toPlainString(),row.path("w30_outbound").decimalValue()); @@ -622,7 +626,7 @@ class InventoryTurnoverSqlTest { } @Test void tinyQualifiedDailySumUsesOneDivisionForSkuAndOwnerThroughProductionCtes() throws Exception { seed("A","ONE",30);seed("A","TWO",30); - execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE WHEN snapshot_run_id=100 AND product_quality=0 THEN CASE WHEN sku_code='ONE' THEN 0.000001 ELSE 0.000002 END ELSE 0 END WHERE snapshot_run_id>=100"); + execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=CASE WHEN day_version_id=100 AND product_quality=0 THEN CASE WHEN sku_code='ONE' THEN 0.000001 ELSE 0.000002 END ELSE 0 END WHERE day_version_id>=100"); execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE WHEN business_date=DATE '"+END+"' THEN CASE WHEN sku_code='ONE' THEN 1 ELSE 2 END ELSE 0 END WHERE warehouse_code='W'"); for(String group:List.of("SKU","OWNER")) { var q=request(group,"ALL","TURNOVER_DESC",null,null,1,20);var p=input(q,30,30,true); @@ -636,7 +640,7 @@ class InventoryTurnoverSqlTest { } for(var row:query("page",p))decimal("30000000",row.get("turnover")); } - execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=0 WHERE snapshot_run_id>=100"); + execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=0 WHERE day_version_id>=100"); for(String group:List.of("SKU","OWNER")) { var q=request(group,"ALL","TURNOVER_DESC",null,null,1,20); for(var row:reportRows(input(q,30,30,true))) { @@ -668,7 +672,7 @@ class InventoryTurnoverSqlTest { } @Test void turnoverOrderingUsesUnroundedRatioBeforeStableSkuAndOwnerPages() throws Exception { seed("Z","HIGH",30);seed("A","TIE",30);seed("B","TIE",30);seed("L","LOW",30); - execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE WHEN product_quality=0 THEN 10 ELSE 0 END WHERE snapshot_run_id>=100"); + execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=CASE WHEN product_quality=0 THEN 10 ELSE 0 END WHERE day_version_id>=100"); execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE WHEN business_date=DATE '"+END+"' THEN CASE sku_code WHEN 'HIGH' THEN 10.000001 WHEN 'LOW' THEN 9.999999 ELSE 10 END ELSE 0 END WHERE warehouse_code='W'"); for(String group:List.of("SKU","OWNER")) for(int page=1;page<=4;page++) { var q=request(group,"ALL","TURNOVER_DESC",null,null,page,1); @@ -687,6 +691,7 @@ class InventoryTurnoverSqlTest { void numericSortsUseFormalValuesInBothDirectionsAndKeepUnknownLast(String metric) throws Exception { seed("L","SKU",30);seed("H","SKU",30);seed("U","SKU",30); execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END,available_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END,transit_quantity=CASE customer_code WHEN 'L' THEN 2 WHEN 'H' THEN 8 ELSE NULL END,frozen_quantity=CASE customer_code WHEN 'L' THEN 1 WHEN 'H' THEN 9 ELSE NULL END"); + execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END"); execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE customer_code WHEN 'L' THEN 1 WHEN 'H' THEN 5 ELSE 9999 END,quantity_unit=CASE customer_code WHEN 'U' THEN 'UNKNOWN' ELSE 'PCS' END"); for(String group:List.of("SKU","OWNER")) { if(group.equals("OWNER")&&metric.equals("COVERAGE")) continue; @@ -742,6 +747,10 @@ class InventoryTurnoverSqlTest { execute("INSERT INTO ll_selfop_sku_profile VALUES("+run+",'demo','W','"+owner+"','"+sku+"','Fixture','PCS','INVENTORY_OBSERVATION',1,TIMESTAMP '2026-01-01 00:00:00')"); } private void stock(long run,String owner,String sku,int quality,int physical,int available) throws Exception { + if(run>=100) { + execute("MERGE INTO ll_selfop_inventory_day_version KEY(id) VALUES("+run+",'demo','W','FLOW_EOD_V1',NULL)"); + execute("INSERT INTO ll_selfop_inventory_daily VALUES("+run+",'"+owner+"','"+sku+"',"+quality+","+physical+",'VALID',NULL)");return; + } execute("INSERT INTO ll_selfop_inventory_snapshot VALUES("+run+",'demo','W','"+owner+"','"+sku+"',"+quality+","+physical+","+available+",0,0,'VALID',NULL)"); } private void execute(String sql) throws Exception {try(var s=db.createStatement()){s.execute(sql);}} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java index 50856827c..947d374b6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java @@ -29,6 +29,25 @@ class SelfopEgressServiceTest { private void success(EgressContext c,Operation op){service.authorize(new AuthorizationRequest(c,op,PROVIDER));service.complete(new CompletionRequest(c,op,PROVIDER,1,null,200,200,false,null));clock.advance(5);} private SelfopApiBudget budget(){return SelfopSupport.read(account.getApiBudgetJson(),SelfopApiBudget.class);} private SelfopValidationBudget rootBudget(){return SelfopSupport.read(task.getValidationBudgetJson(),SelfopValidationBudget.class);} + @Test void historyPagesRequireExactFrozenSliceCheckpointAndEnabledWorker(){ + continuousBusinessRoot();cfg.setInventoryHistoryEnabled(true); + var slice=new com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice("A",now().minusDays(1).withNano(0),now().withNano(0),1000); + task.setTaskType(com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice.TYPE); + task.setScopeHash(SelfopSupport.hash(slice));task.setRequestHash(task.getScopeHash());task.setQueryScopeJson(SelfopSupport.json(slice)); + task.setPlannedAtUtc(now().toLocalDate().atStartOfDay());task.setQueryFromUtc(LocalDateTime.ofInstant(slice.from(),ZoneOffset.UTC));task.setQueryUntilUtc(LocalDateTime.ofInstant(slice.until(),ZoneOffset.UTC)); + task.setRequestKey("continuous:history:"+task.getPlannedAtUtc().toLocalDate()+":"+task.getScopeHash()); + run.setProgressSnapshotJson("{\"nextPage\":1}"); + assertThat(SelfopCollectionPolicy.continuousBusinessRoot(task)).isTrue(); + assertThatThrownBy(()->service.reserve(context(),Operation.INVENTORY_FLOW,1000,"B",null,slice.requestHash(1))).hasMessage("EGRESS_HISTORY_INVALID"); + assertThatThrownBy(()->service.reserve(context(),Operation.INVENTORY_FLOW,1000,"A",null,slice.requestHash(2))).hasMessage("EGRESS_HISTORY_INVALID"); + cfg.setInventoryHistoryEnabled(false); + assertThatThrownBy(()->service.reserve(context(),Operation.INVENTORY_FLOW,1000,"A",null,slice.requestHash(1))).hasMessage("EGRESS_HISTORY_INVALID"); + cfg.setInventoryHistoryEnabled(true); + var first=service.reserve(context(),Operation.INVENTORY_FLOW,1000,"A",null,slice.requestHash(1));success(first,Operation.INVENTORY_FLOW); + run.setProgressSnapshotJson("{\"nextPage\":2}"); + var second=service.reserve(context(),Operation.INVENTORY_FLOW,1000,"A",null,slice.requestHash(2));success(second,Operation.INVENTORY_FLOW); + assertThat(businessBudget().http).isEqualTo(2);assertThat(budget().confirmedHttp).isEqualTo(2); + } @Test void masterDataOperationsUseBoundBusinessTaskAndExistingSingleFlightGate(){ continuousBusinessRoot();task.setTaskType("MASTER_DATA");task.setQueryFromUtc(null);task.setQueryUntilUtc(null); task.setPlannedAtUtc(now().plusHours(8).toLocalDate().atStartOfDay().minusHours(8)); From 235b9469d974c78f182237029d9b6bb26c8bacf0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 15 Oct 2026 13:48:49 +0800 Subject: [PATCH 5/7] fix(selfop): remove obsolete execution binding on config refresh --- deploy/env/.env.test.example | 1 - .../src/main/resources/application.yml | 1 - .../SelfopAnalyticsPropertiesBindingTest.java | 38 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsPropertiesBindingTest.java diff --git a/deploy/env/.env.test.example b/deploy/env/.env.test.example index 09111ffc5..4af28c729 100644 --- a/deploy/env/.env.test.example +++ b/deploy/env/.env.test.example @@ -30,7 +30,6 @@ SELFOP_SOURCE_ACCOUNT=lingxing-primary # 未核准额度使用0,真实出口拒绝请求;有效模式仍需显式的独占交接证明与截止时间。 SELFOP_DAILY_REQUEST_BUDGET=0 SELFOP_MIN_REQUEST_INTERVAL_MS=5000 -SELFOP_EXECUTION_ENABLED=false SELFOP_SALES_PUBLICATION_ENABLED=false REDIS_PASSWORD= AUTH_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 98981b1d6..e8c1bb6c9 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -52,7 +52,6 @@ baiyue: environment: ${DEPLOY_ENV:test} daily-request-budget: ${SELFOP_DAILY_REQUEST_BUDGET:0} business-min-request-interval-ms: ${SELFOP_MIN_REQUEST_INTERVAL_MS:5000} - execution-enabled: ${SELFOP_EXECUTION_ENABLED:false} sales-publication-enabled: ${SELFOP_SALES_PUBLICATION_ENABLED:false} integration-base-url: http://integration-service:9210 internal-service-token: ${INTERNAL_SERVICE_TOKEN:} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsPropertiesBindingTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsPropertiesBindingTest.java new file mode 100644 index 000000000..e9766ab97 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsPropertiesBindingTest.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.BindException; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.io.ClassPathResource; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; + +class SelfopAnalyticsPropertiesBindingTest { + StandardEnvironment environment() throws Exception { + var env=new StandardEnvironment(); + for(var source:new YamlPropertySourceLoader().load("application",new ClassPathResource("application.yml")))env.getPropertySources().addLast(source); + env.getPropertySources().addFirst(new MapPropertySource("runtime",Map.of( + "baiyue.self-operated-analytics.collection-mode","BUSINESS", + "baiyue.self-operated-analytics.environment","test", + "baiyue.self-operated-analytics.inventory-history-enabled",true))); + return env; + } + @Test void refreshingRunningBusinessBeanBindsHistoryFlagWithoutDerivedExecutionInput() throws Exception { + var config=new SelfopAnalyticsProperties();config.setCollectionMode("BUSINESS"); + var env=environment(); + assertNull(env.getProperty("baiyue.self-operated-analytics.execution-enabled")); + Binder.get(env).bind("baiyue.self-operated-analytics",Bindable.ofInstance(config)); + assertTrue(config.isInventoryHistoryEnabled());assertTrue(config.isExecutionEnabled()); + Binder.get(env).bind("baiyue.self-operated-analytics",Bindable.ofInstance(config)); + assertTrue(config.isInventoryHistoryEnabled());assertTrue(config.isExecutionEnabled()); + } + @Test void obsoleteFalseInputReproducesTheRefreshFailure() throws Exception { + var config=new SelfopAnalyticsProperties();config.setCollectionMode("BUSINESS"); + var env=environment();env.getPropertySources().addFirst(new MapPropertySource("obsolete",Map.of("baiyue.self-operated-analytics.execution-enabled",false))); + assertThrows(BindException.class,()->Binder.get(env).bind("baiyue.self-operated-analytics",Bindable.ofInstance(config))); + } +} From 22d79e08f91dcadcacdf19a14b21b097462b260d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 15 Oct 2026 14:13:41 +0800 Subject: [PATCH 6/7] fix(selfop): restore unique same-second inventory chains --- .../history/InventoryHistoryBuilder.java | 4 +- .../history/InventoryHistoryCalculator.java | 77 +++++++++++++--- .../InventoryHistoryCalculatorTest.java | 90 +++++++++++++++++++ .../history/InventoryHistoryPipelineTest.java | 38 ++++++++ 4 files changed, 194 insertions(+), 15 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryBuilder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryBuilder.java index f471266e0..1ffa3ad74 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryBuilder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryBuilder.java @@ -109,12 +109,12 @@ public class InventoryHistoryBuilder { var sales=jdbc.queryForList("SELECT published_run_id FROM ll_selfop_sync_scope WHERE source_account=? AND warehouse_code=? AND data_family='OUTBOUND'",account,warehouse); Long salesRun=sales.isEmpty()||sales.get(0).get("published_run_id")==null?null:((Number)sales.get(0).get("published_run_id")).longValue(); String inputHash=versions.factHash(account,warehouse); - String signature=hash(Arrays.asList(inputHash,anchor,reference,from,until,salesRun,coverage)); + String signature=hash(Arrays.asList(RULE,CALCULATION_REVISION,inputHash,anchor,reference,from,until,salesRun,coverage)); if(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=? AND task_type=? AND request_hash=? AND status='SUCCEEDED'",Long.class,account,TYPE,signature)>0)return null; // Failed identical inputs get bounded attempts, instead of an infinite hot loop. if(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=? AND task_type=? AND request_hash=?",Long.class,account,TYPE,signature)>=3)return null; long taskId=IdWorker.getId(),runId=IdWorker.getId(),group=IdWorker.getId();var now=utcNow(); - String evidence=json(Map.of("warehouse",warehouse,"anchor",anchor,"reference",reference,"coverageRuns",coverageRuns,"inputStagesRun",runId,"rule",RULE)); + String evidence=json(Map.of("warehouse",warehouse,"anchor",anchor,"reference",reference,"coverageRuns",coverageRuns,"inputStagesRun",runId,"rule",RULE,"calculationRevision",CALCULATION_REVISION)); var task=new SelfopSyncTask();task.setId(taskId);task.setTaskNo("LXRESTORE-"+UUID.randomUUID().toString().replace("-","")); task.setSourceAccount(account);task.setDiscoveryScopeId(jdbc.queryForObject("SELECT id FROM ll_selfop_outbound_discovery_scope WHERE source_account=?",Long.class,account));task.setExecutionPurpose("BUSINESS"); task.setTaskRole("ACCOUNT_DISCOVERY");task.setTaskType(TYPE);task.setRequestKey("history-build:"+UUID.randomUUID());task.setRequestHash(signature); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryCalculator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryCalculator.java index ec4d54a15..746d164fa 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryCalculator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryCalculator.java @@ -7,6 +7,7 @@ import java.util.*; /** Pure reconstruction over frozen, complete source intervals. Never invents absent stock. */ public final class InventoryHistoryCalculator { public static final String RULE = "FLOW_EOD_V1"; + public static final int CALCULATION_REVISION = 2; private static final Set VERIFIED_TYPES = Set.of("DO", "PA", "IMO", "IAT", "RI"); private static final ZoneOffset BUSINESS = ZoneOffset.ofHours(8); private InventoryHistoryCalculator() { } @@ -97,12 +98,9 @@ public final class InventoryHistoryCalculator { for(Flow f:chain) seconds.computeIfAbsent(f.at(),k->new ArrayList<>()).add(f); BigDecimal prior=null; for(var group:seconds.values()) { - if(group.size()>8) return "CHAIN_ORDER_AMBIGUOUS"; - var endings=new ArrayList(); - paths(group,new boolean[group.size()],prior,0,endings); - if(endings.isEmpty()) return "CHAIN_DISCONTINUITY"; - if(endings.size()!=1) return "CHAIN_ORDER_AMBIGUOUS"; - prior=endings.get(0); + var resolved=resolveChain(group,prior); + if(resolved.reason()!=null) return resolved.reason(); + prior=resolved.ending(); } } var deltas=new TreeMap(Comparator.reverseOrder()); @@ -114,14 +112,67 @@ public final class InventoryHistoryCalculator { } return null; } - private static void paths(List group,boolean[] used,BigDecimal balance,int depth,List endings) { - if(endings.size()>1) return; - if(depth==group.size()) { endings.add(balance); return; } - for(int i=0;i group,BigDecimal prior) { + // Every flow is a distinct directed edge: opening balance -> closing balance. + // A valid order is an Euler trail, using every edge exactly once. + var degree=new TreeMap(); + for(var f:group) { + degree.merge(f.closing().subtract(f.delta()),1,Integer::sum); + degree.merge(f.closing(),-1,Integer::sum); } + BigDecimal start=null;int starts=0,ends=0; + for(var entry:degree.entrySet()) { + int difference=entry.getValue(); + if(difference==1){start=entry.getKey();starts++;} + else if(difference==-1)ends++; + else if(difference!=0)return new ChainResult(null,"CHAIN_DISCONTINUITY"); + } + if(!((starts==1&&ends==1)||(starts==0&&ends==0)))return new ChainResult(null,"CHAIN_DISCONTINUITY"); + if(prior!=null) { + if(start!=null&&start.compareTo(prior)!=0)return new ChainResult(null,"CHAIN_DISCONTINUITY"); + start=prior; + } else if(start==null)start=group.get(0).closing().subtract(group.get(0).delta()); + var first=orderedTrail(group,start,false); + if(first==null)return new ChainResult(null,"CHAIN_DISCONTINUITY"); + // Without a prior balance a circuit with multiple edges admits rotated orders. + if(prior==null&&starts==0&&group.size()>1)return new ChainResult(null,"CHAIN_ORDER_AMBIGUOUS"); + // Hierholzer with opposite edge priorities yields the lexicographically first + // and last complete trails. Equality proves uniqueness, including parallel edges. + // Input indices only define opposing priorities; neither IDs nor response order + // are accepted as chronology. Two iterative passes avoid factorial search/recursion. + var last=orderedTrail(group,start,true); + if(!Arrays.equals(first,last))return new ChainResult(null,"CHAIN_ORDER_AMBIGUOUS"); + return new ChainResult(group.get(first[first.length-1]).closing(),null); + } + private static int[] orderedTrail(List group,BigDecimal start,boolean reverse) { + var outgoing=new TreeMap>(); + for(int i=0;inew ArrayDeque<>()).addLast(i); + } + var stack=new ArrayDeque(); + int[] trail=new int[group.size()];int position=trail.length; + BigDecimal balance=start; + while(true) { + var edges=outgoing.get(balance); + if(edges!=null&&!edges.isEmpty()) { + int edge=reverse?edges.removeLast():edges.removeFirst(); + stack.push(edge);balance=group.get(edge).closing(); + } else { + if(stack.isEmpty())break; + int edge=stack.pop();trail[--position]=edge; + var f=group.get(edge);balance=f.closing().subtract(f.delta()); + } + } + if(position!=0)return null; // Disconnected edges cannot be silently discarded. + balance=start; + for(int edge:trail) { + var f=group.get(edge); + if(balance.add(f.delta()).compareTo(f.closing())!=0)return null; + balance=f.closing(); + } + return trail; } public static boolean covered(List intervals,Instant from,Instant until) { Instant cursor=from; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryCalculatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryCalculatorTest.java index c3921f5d4..4316a78e4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryCalculatorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryCalculatorTest.java @@ -69,6 +69,96 @@ class InventoryHistoryCalculatorTest { var rows=List.of(flow("1",GOOD,"DO","-1","38","2026-10-15T01:00:00"),flow("2",GOOD,"DO","-1","35","2026-10-15T02:00:00")); assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"35")))).get(0).reason()).isEqualTo("CHAIN_DISCONTINUITY"); } + @Test void restoresHundredSameSecondDecrementsAcrossSixtyDaysRegardlessOfSourceOrder() { + var group=new ArrayList(); + for(int i=0;i<100;i++)group.add(flow("F"+(100-i),GOOD,"DO","-1",String.valueOf(726-i),"2026-10-15T01:00:00")); + for(int seed=0;seed<10;seed++) { + Collections.shuffle(group,new Random(seed)); + for(boolean hasPrior:List.of(false,true)) { + var rows=new ArrayList<>(group); + if(hasPrior)rows.add(flow("prior",GOOD,"DO","-1","727","2026-10-14T23:59:59")); + var i=input(rows,List.of(anchor(GOOD,"627"))); + var result=reconstruct(new Input(DAY.minusDays(59),i.until(),i.candidates(),i.flows(),i.anchors(), + List.of(new Interval(DAY.minusDays(59).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),at("2026-10-15T08:00:02"))))); + assertThat(result).hasSize(60).allMatch(d->"VALID".equals(d.status())); + assertThat(result.get(59).quantity()).isEqualByComparingTo("727"); + assertThat(result.get(0).quantity()).isEqualByComparingTo(hasPrior?"728":"727"); + } + } + } + @Test void forcedCycleBeforeExitIsUniqueDespiteMultipleOutgoingEdgesAndDecimalScales() { + // Taking the exit first is a dead end, not a second complete order. + var rows=List.of(flow("exit",GOOD,"DO","-1.00","0.0","2026-10-15T01:00:00"), + flow("up",GOOD,"PA","1","2.000","2026-10-15T01:00:00"), + flow("down",GOOD,"DO","-1","1.000000","2026-10-15T01:00:00")); + assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"10")))).get(0).status()).isEqualTo("VALID"); + } + @Test void twoCyclesWithSameFinalBalanceRemainAmbiguous() { + var rows=List.of(flow("prior",GOOD,"PA","0","2","2026-10-15T00:59:59"), + flow("a",GOOD,"PA","1","3","2026-10-15T01:00:00"),flow("b",GOOD,"DO","-1","2","2026-10-15T01:00:00"), + flow("c",GOOD,"PA","2","4","2026-10-15T01:00:00"),flow("d",GOOD,"DO","-2","2","2026-10-15T01:00:00")); + assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"10")))).get(0).reason()).isEqualTo("CHAIN_ORDER_AMBIGUOUS"); + } + @Test void distinctIdsWithDuplicateEdgesAreNotDeduplicatedIntoAUniqueOrder() { + var rows=List.of(flow("a",GOOD,"PA","1","2","2026-10-15T01:00:00"), + flow("b",GOOD,"PA","1","2","2026-10-15T01:00:00"),flow("return",GOOD,"DO","-1","1","2026-10-15T01:00:00")); + assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"10")))).get(0).reason()).isEqualTo("CHAIN_ORDER_AMBIGUOUS"); + } + @Test void disconnectedCycleCannotBeIgnoredEvenWhenDegreesMatch() { + var rows=List.of(flow("path",GOOD,"DO","-1","1","2026-10-15T01:00:00"), + flow("loop",GOOD,"PA","0","3","2026-10-15T01:00:00")); + assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"10")))).get(0).reason()).isEqualTo("CHAIN_DISCONTINUITY"); + } + @Test void longChainWithMissingEdgeOrWrongPriorRemainsUnknown() { + var rows=new ArrayList(); + for(int i=0;i<100;i++)rows.add(flow("F"+i,GOOD,"DO","-1",String.valueOf(726-i),"2026-10-15T01:00:00")); + rows.add(flow("prior",GOOD,"PA","0","728","2026-10-15T00:59:59")); + assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"627")))).get(0).reason()).isEqualTo("CHAIN_DISCONTINUITY"); + rows.remove(rows.size()-1);rows.remove(50); + assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"627")))).get(0).reason()).isEqualTo("CHAIN_DISCONTINUITY"); + } + @Test void largeUniqueChainUsesIterativeTraversal() { + var rows=new ArrayList(); + for(int i=0;i<20_000;i++)rows.add(flow("F"+i,GOOD,"DO","-1",String.valueOf(20_000-i),"2026-10-15T01:00:00")); + assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"1")))).get(0).quantity()).isEqualByComparingTo("20001"); + } + @Test void graphSolverMatchesExhaustiveOrdersForAllSmallMultigraphs() { + // Independent permutation oracle: all multisets of up to five directed edges + // on three balances, including loops/parallel edges, with every possible prior. + for(int size=1;size<=5;size++)checkGraphs(new ArrayList<>(),0,size); + } + private void checkGraphs(List edges,int minimum,int remaining) { + if(remaining>0) { + for(int edge=minimum;edge<9;edge++) { + edges.add(edge);checkGraphs(edges,edge,remaining-1);edges.remove(edges.size()-1); + } + return; + } + var group=new ArrayList(); + for(int i=0;i(group); + if(prior>=0)rows.add(flow("prior",GOOD,"PA","0",String.valueOf(prior),"2026-10-15T00:59:59")); + int paths=countOrders(group,new boolean[group.size()],prior<0?null:BigDecimal.valueOf(prior),0); + String reason=paths==0?"CHAIN_DISCONTINUITY":paths==1?null:"CHAIN_ORDER_AMBIGUOUS"; + var actual=reconstruct(input(rows,List.of(anchor(GOOD,"100")))).get(0); + assertThat(actual.reason()).as("edges=%s prior=%s",edges,prior).isEqualTo(reason); + } + } + private int countOrders(List group,boolean[] used,BigDecimal balance,int depth) { + if(depth==group.size())return 1; + int count=0; + for(int i=0;i1)return 2; + } + return count; + } @Test void laterAnchorRevisionChangesEarlierDay() { var a=reconstruct(input(List.of(flow("f",GOOD,"DO","-1","100","2026-10-15T01:00:00")),List.of(anchor(GOOD,"100")))); var b=reconstruct(input(List.of(flow("f",GOOD,"DO","-2","100","2026-10-15T01:00:00")),List.of(anchor(GOOD,"100")))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryPipelineTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryPipelineTest.java index 7c6b77e7b..48d78a109 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryPipelineTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryPipelineTest.java @@ -118,6 +118,44 @@ public class InventoryHistoryPipelineTest { assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_snapshot",Integer.class)); builder.build("demo","W",30);assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_day_version",Integer.class)); } + @Test void calculationRevisionRebuildsLegacyUnknownFromSavedFlowsExactlyOnce() { + stock(100,DAY,727);stock(101,DAY.plusDays(1),627); + var rows=new ArrayList(); + for(int i=0;i<100;i++)rows.add(flow("F"+i,"-1",String.valueOf(726-i))); + Collections.shuffle(rows,new Random(42)); + collect(slice(),DAY,page(1,100,rows)); + collect(new InventoryFlowSlice("W",DAY.plusDays(1).atStartOfDay(),DAY.plusDays(1).atTime(8,0,2),1000),DAY,page(1,0,List.of())); + builder.build("demo","W",30); + // Seed the pre-fix published state, including its exact former dedup signature. + var coverage=List.of(new InventoryHistoryCalculator.Interval( + DAY.atStartOfDay().toInstant(ZoneOffset.ofHours(8)),DAY.plusDays(1).atTime(8,0,2).toInstant(ZoneOffset.ofHours(8)))); + String legacyHash=hash(Arrays.asList(versions.factHash("demo","W"),builder.anchor("demo","W",0), + builder.anchor("demo","W",1),DAY,DAY.plusDays(1),null,coverage)); + var legacyTask=jdbc.queryForMap("SELECT id,request_hash FROM ll_selfop_sync_task WHERE task_type='INVENTORY_HISTORY_BUILD'"); + assertNotEquals(legacyHash,legacyTask.get("request_hash")); + jdbc.update("UPDATE ll_selfop_sync_task SET request_hash=? WHERE id=?",legacyHash,legacyTask.get("id")); + jdbc.update("UPDATE ll_selfop_inventory_daily SET physical_quantity=NULL,quality_status='UNKNOWN',quality_reason='CHAIN_ORDER_AMBIGUOUS'"); + jdbc.update("UPDATE ll_selfop_inventory_day_version SET evidence_json=?",json(Map.of("rule","FLOW_EOD_V1"))); + var flowHash=versions.factHash("demo","W"); + clearInvocations(client); + builder.build("demo","W",30); + assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE task_type='INVENTORY_HISTORY_BUILD' AND status='SUCCEEDED'",Integer.class)); + assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_day_version WHERE head_slot=1",Integer.class)); + var current=jdbc.queryForMap("SELECT d.physical_quantity,d.quality_status,v.rule_version,v.evidence_json FROM ll_selfop_inventory_daily d JOIN ll_selfop_inventory_day_version v ON v.id=d.day_version_id WHERE v.head_slot=1"); + assertEquals(0,new BigDecimal("627").compareTo((BigDecimal)current.get("physical_quantity"))); + assertEquals("VALID",current.get("quality_status")); + assertEquals("FLOW_EOD_V1",current.get("rule_version")); + var evidence=read(current.get("evidence_json").toString(),JsonNode.class); + assertEquals(InventoryHistoryCalculator.CALCULATION_REVISION,evidence.path("calculationRevision").asInt()); + var runEvidence=read(jdbc.queryForObject("SELECT scope_snapshot_json FROM ll_selfop_sync_run WHERE status='FACTS_APPLIED' AND last_phase='HISTORY_PUBLISHED' ORDER BY id DESC LIMIT 1",String.class),JsonNode.class); + assertEquals(InventoryHistoryCalculator.CALCULATION_REVISION,runEvidence.path("calculationRevision").asInt()); + builder.build("demo","W",30); + assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_day_version",Integer.class)); + assertEquals(flowHash,versions.factHash("demo","W")); + assertEquals(100,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_flow",Integer.class)); + assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_snapshot",Integer.class)); + verifyNoInteractions(client); + } void stock(long id,LocalDate day,int quantity) { var now=day.atTime(0,0);var r=new SelfopSyncRun();r.setId(id);r.setTaskId(id);r.setAttemptNo(1);r.setSourceAccount("demo");r.setWarehouseCode("W");r.setScopeId(2L);r.setRunRole("WAREHOUSE_SYNC");r.setExecutionPurpose("BUSINESS");r.setScopeHash("b".repeat(64));r.setScopeSnapshotJson("{}");r.setFenceToken(0L);r.setStatus("PUBLISHED");r.setLastPhase("PUBLISHED");r.setCoverageStatus("COMPLETE");r.setSourceTotalCount(1L);r.setUniqueObjectCount(1L);r.setPageCount(1);r.setCreatedAt(now);r.setUpdatedAt(now);r.setFetchStartedAtUtc(now);r.setFetchFinishedAtUtc(now.plusSeconds(1));r.setPublishedAtUtc(now.plusSeconds(2));runs.insert(r); jdbc.update("INSERT INTO ll_selfop_inventory_snapshot(id,created_at,updated_at,snapshot_run_id,source_account,customer_code,warehouse_code,sku_code,product_quality,physical_quantity,observed_at_utc,quality_status) VALUES(?,?,?,?,'demo','C','W','SKU',0,?,?,'VALID')",id,now,now,id,quantity,now.plusSeconds(1)); From dbb93a06c26c001d572272e153084b22038cfc60 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 15 Oct 2026 15:00:03 +0800 Subject: [PATCH 7/7] feat(selfop): provide frozen turnover batches for backend Excel exports --- config/nacos/test/file-service.yml | 9 + deploy/sql/README.md | 2 + .../sql/selfop-turnover-export-permission.sql | 11 ++ deploy/sql/头程数据库统一增量-MySQL8.sql | 2 + .../file/service/ExportTaskService.java | 29 +++- file/src/main/resources/application.yml | 9 + .../openapi/selfop-turnover-export-v1.yaml | 50 ++++++ .../LastlegExportDefinitionContractTest.java | 3 +- .../file/service/ExportTaskServiceTest.java | 26 +++ .../service/GenericXlsxExportRunnerTest.java | 18 ++ .../db/selfoperated-analytics-permissions.sql | 4 +- .../analyze-lastleg-dependencies.test.mjs | 2 +- .../export/LastlegExportProviderService.java | 9 +- .../query/InventoryTurnoverExport.java | 133 +++++++++++++++ .../query/TurnoverExportReasons.java | 154 ++++++++++++++++++ .../resources/openapi/lastleg-exports-v1.yaml | 5 +- .../query/InventoryTurnoverExportTest.java | 84 ++++++++++ .../lastleg-slice-dependencies.json | 5 + 18 files changed, 546 insertions(+), 9 deletions(-) create mode 100644 deploy/sql/selfop-turnover-export-permission.sql create mode 100644 file/src/main/resources/openapi/selfop-turnover-export-v1.yaml create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverExport.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/TurnoverExportReasons.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverExportTest.java diff --git a/config/nacos/test/file-service.yml b/config/nacos/test/file-service.yml index 48155dcdb..740fdf618 100644 --- a/config/nacos/test/file-service.yml +++ b/config/nacos/test/file-service.yml @@ -141,6 +141,15 @@ baiyue: max-rows: 200000 max-attempts: 3 batch-size: 200 + SELFOP_INVENTORY_TURNOVER: + provider-service: lastleg-service + provider-path: /internal/exports/v1/SELFOP_INVENTORY_TURNOVER/batches + required-permission: platform:self-operated-analytics:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 100 OVERSEAS_INVENTORY_LEDGER: provider-service: lastleg-service provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_LEDGER/batches diff --git a/deploy/sql/README.md b/deploy/sql/README.md index e2293ff25..942e6c642 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -151,3 +151,5 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test 码头车队邮件配置见 [Firstleg 车队邮件](../../firstleg/docs/terminal-drayage-mail.md)。 `migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql` 仅新增废弃照片关联表,按客户、申请和托盘关联文件及提交时间。与 Lastleg、File 和三端照片功能共同发布;不回填历史废弃单,不修改库存。脚本存在不表示目标环境已执行。 + +自营库存周转后端导出权限见 `selfop-turnover-export-permission.sql`,仅新增 `platform:self-operated-analytics:export` 并授予平台管理员;同步 IAM 自营权限源及国内统一基线。配套部署 Lastleg、File、Admin,File 注册 `SELFOP_INVENTORY_TURNOVER`(Nacos同名配置同步);无业务建表及采集配置变更。执行前须逐项授权。 diff --git a/deploy/sql/selfop-turnover-export-permission.sql b/deploy/sql/selfop-turnover-export-permission.sql new file mode 100644 index 000000000..a1a03d44c --- /dev/null +++ b/deploy/sql/selfop-turnover-export-permission.sql @@ -0,0 +1,11 @@ +-- 自营库存周转后端导出权限;不建表,不修改销量、库存或采集任务。 +USE baiyue_iam; +SET NAMES utf8mb4; +START TRANSACTION; +INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES + (5118002,0,'PLATFORM','API',5118120,'platform:self-operated-analytics:export','导出平台自营库存周转','/api/platform/v1/files/export-tasks','POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),route_path=VALUES(route_path),http_method=VALUES(http_method),status='ACTIVE',deleted=0,updated_at=CURRENT_TIMESTAMP(3); +INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) +SELECT 4000001,5118002,0,CURRENT_TIMESTAMP(3) +WHERE NOT EXISTS (SELECT 1 FROM iam_role_permission WHERE role_id=4000001 AND permission_id=5118002 AND tenant_id=0); +COMMIT; diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index db5c6cbdd..100c68e60 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -8323,10 +8323,12 @@ INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,perm (5118120,0,'PLATFORM','MENU',5118100,'platform:self-operated:inventory:menu','库存分析','/self-operated/inventory','GET',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), (5118130,0,'PLATFORM','MENU',5118100,'platform:self-operated:collection:menu','数据采集','/self-operated/collection','GET',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), (5118000,0,'PLATFORM','API',5118100,'platform:self-operated-analytics:list','读取平台自营出库销量与库存分析','/api/platform/v1/last-leg/self-operated-analytics','GET',40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118002,0,'PLATFORM','API',5118120,'platform:self-operated-analytics:export','导出平台自营库存周转','/api/platform/v1/files/export-tasks','POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), (5118001,0,'PLATFORM','API',5118130,'platform:self-operated-analytics:sync','平台自营采集重试与重采集','/api/platform/v1/last-leg/self-operated-analytics/sync-tasks','POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5118000,0,CURRENT_TIMESTAMP(3)), (4000001,5118001,0,CURRENT_TIMESTAMP(3)), + (4000001,5118002,0,CURRENT_TIMESTAMP(3)), (4000001,5118100,0,CURRENT_TIMESTAMP(3)), (4000001,5118140,0,CURRENT_TIMESTAMP(3)), (4000001,5118110,0,CURRENT_TIMESTAMP(3)), diff --git a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java index 2bf1f64fb..dca2e30c0 100644 --- a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java +++ b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java @@ -21,9 +21,11 @@ import java.time.LocalDateTime; import java.util.Locale; import java.util.Map; import java.util.TreeMap; +import java.util.Set; @Service public class ExportTaskService { + private static final String TURNOVER = "SELFOP_INVENTORY_TURNOVER"; @Resource private ExportTaskRepository repository; @Resource private ExportStorage storage; @Resource private ObjectMapper objectMapper; @@ -34,6 +36,7 @@ public class ExportTaskService { public ExportTaskResponse create(AuthenticatedUser user, CreateExportTaskRequest request) { requirePlatform(user); String type = request.exportType().trim().toUpperCase(Locale.ROOT); + if (TURNOVER.equals(type) && user.impersonated()) throw new BizException(ResultCode.FORBIDDEN, "平台自营导出不支持代登录"); String format = request.format().trim().toUpperCase(Locale.ROOT); var definition = registry.require(type); if (!"XLSX".equals(format)) throw new BizException(ResultCode.VALIDATION_FAILED, "当前仅支持 XLSX 格式"); @@ -135,6 +138,9 @@ public class ExportTaskService { } private void requireExportCostAccess(AuthenticatedUser user, FileExportTask task) { + if (TURNOVER.equals(task.getExportType()) && (user.impersonated() || !user.permissions().contains("platform:self-operated-analytics:export"))) { + throw new BizException(ResultCode.FORBIDDEN, "缺少平台自营导出权限"); + } if (!"TRIP_OPERATIONS_LIST".equals(task.getExportType())) return; try { if (objectMapper.readTree(task.getParametersJson()).path("costVisible").asBoolean(false) @@ -172,11 +178,14 @@ public class ExportTaskService { } values.forEach((key, value) -> { if (key == null || key.isBlank() || key.length() > 64) throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选键无效"); - validateParameterValue(value, 1); + if (TURNOVER.equals(type) && "snapshotContext".equals(key)) { + if (!(value instanceof String text) || text.isBlank() || text.length() > 16000) + throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转查询版本无效,请刷新后重新导出"); + } else validateParameterValue(value, 1); }); try { int maxBytes = ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST.equals(type) - ? 512 * 1024 : 4096; + ? 512 * 1024 : TURNOVER.equals(type) ? 64 * 1024 : 4096; if (objectMapper.writeValueAsBytes(values).length > maxBytes) { throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选条件不能超过 " + (maxBytes / 1024) + "KB"); @@ -210,6 +219,22 @@ public class ExportTaskService { } private void validateTypeParameters(String type, Map parameters) { + if (TURNOVER.equals(type)) { + Map values=parameters==null?Map.of():parameters; + if (!values.keySet().containsAll(Set.of("sourceAccount","warehouseCode","endDate","baseWindowDays","snapshotContext","columns","expectedTotal")) + || !Set.of("sourceAccount","warehouseCode","endDate","baseWindowDays","groupBy","customerCodes","sku","focus","coverageDaysThreshold","turnoverStatus","sortBy","hideZeroInventory","snapshotContext","columns","expectedTotal").containsAll(values.keySet())) + throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转导出筛选无效"); + if (!(values.get("columns") instanceof java.util.List columns) || columns.isEmpty() || columns.size()>30 + || columns.stream().anyMatch(v -> !(v instanceof String)) || new java.util.HashSet<>(columns).size()!=columns.size()) + throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转导出列无效"); + if (values.containsKey("customerCodes") && (!(values.get("customerCodes") instanceof java.util.List owners) || owners.size()>100 || owners.stream().anyMatch(v -> !(v instanceof String)))) + throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转货主范围无效"); + try { + long count=new java.math.BigDecimal(String.valueOf(values.get("expectedTotal"))).longValueExact(); + if(count<0 || count>200000) throw new ArithmeticException(); + } catch(RuntimeException e) { throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转导出条数无效"); } + return; + } if (!ExportSchemaContracts.BUY_DECLARATION_ITEMS.equals(type)) return; Map values = parameters == null ? Map.of() : parameters; if (!values.keySet().equals(java.util.Set.of("loadingPlanId"))) { diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index 1dc24570c..001eb5dc2 100644 --- a/file/src/main/resources/application.yml +++ b/file/src/main/resources/application.yml @@ -224,6 +224,15 @@ baiyue: max-rows: 200000 max-attempts: 3 batch-size: 200 + SELFOP_INVENTORY_TURNOVER: + provider-service: lastleg-service + provider-path: /internal/exports/v1/SELFOP_INVENTORY_TURNOVER/batches + required-permission: platform:self-operated-analytics:export + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 100 OVERSEAS_INVENTORY_LEDGER: provider-service: lastleg-service provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_LEDGER/batches diff --git a/file/src/main/resources/openapi/selfop-turnover-export-v1.yaml b/file/src/main/resources/openapi/selfop-turnover-export-v1.yaml new file mode 100644 index 000000000..b43137b17 --- /dev/null +++ b/file/src/main/resources/openapi/selfop-turnover-export-v1.yaml @@ -0,0 +1,50 @@ +openapi: 3.0.3 +info: + title: 库存周转后台导出 + version: 1.0.0 + description: File 下载中心生成 XLSX,Lastleg 内部 Provider 按固定库存周转版本供数。浏览器不分页取数、不生成文件。 +paths: + /api/platform/v1/files/export-tasks: + post: + summary: 提交库存周转 Excel 导出 + description: | + 仅平台、非代登录身份及 platform:self-operated-analytics:export 权限。 + 保留列表的 sourceAccount、warehouseCode、endDate、baseWindowDays、groupBy、customerCodes、sku、focus、coverageDaysThreshold、turnoverStatus、sortBy、hideZeroInventory、snapshotContext。 + columns 为当前显示列的有序白名单键,服务端决定列名、格式并追加周转率证据列;expectedTotal 为已查询总条数,范围0至200000。 + 不接受 current/size/includeOverview。snapshotContext 最长16000字符,整组参数最大64KiB。空结果生成只有表头的文件。 + 后台每批最多100条,沿用固定上下文30分钟有效期;过期、服务重启或历史版本失效须刷新列表重新提交,不自动改用新版本。 + 版本或条数变化、分页缺失、超行数、生成失败时不提供部分文件。下载中心复用进度、临时失败退避重试、归属校验及过期规则。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [exportType, format, taskName, parameters] + properties: + exportType: {type: string, enum: [SELFOP_INVENTORY_TURNOVER]} + format: {type: string, enum: [XLSX]} + taskName: {type: string, maxLength: 128} + parameters: + type: object + required: [sourceAccount, warehouseCode, endDate, baseWindowDays, snapshotContext, columns, expectedTotal] + properties: + snapshotContext: {type: string, minLength: 1, maxLength: 16000} + columns: {type: array, minItems: 1, maxItems: 30, uniqueItems: true, items: {type: string}} + expectedTotal: {type: integer, minimum: 0, maximum: 200000} + additionalProperties: true + responses: + '200': {description: ApiResponse 包装下载中心任务;业务错误遵循通用 code 契约} + '403': {description: 无平台自营导出权限或非平台身份} + /api/platform/v1/files/export-tasks/{taskId}/download: + get: + summary: 下载后台生成的库存周转 Excel + description: 校验任务归属、完成状态、有效期,以及当前非代登录身份和平台自营导出权限。 + parameters: + - {name: taskId, in: path, required: true, schema: {type: string}} + responses: + '200': + description: XLSX 文件 + content: + application/vnd.openxmlformats-officedocument.spreadsheetml.sheet: + schema: {type: string, format: binary} diff --git a/file/src/test/java/com/baiyue/file/config/LastlegExportDefinitionContractTest.java b/file/src/test/java/com/baiyue/file/config/LastlegExportDefinitionContractTest.java index 15c4872e9..8485da15c 100644 --- a/file/src/test/java/com/baiyue/file/config/LastlegExportDefinitionContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/LastlegExportDefinitionContractTest.java @@ -13,6 +13,7 @@ class LastlegExportDefinitionContractTest { Map nacos = definitions(Path.of("../config/nacos/test/file-service.yml")); Map expected = Map.ofEntries( Map.entry("LAST_MILE_ORDER_LIST", "platform:overseas-warehouse:last-mile-order:export"), + Map.entry("SELFOP_INVENTORY_TURNOVER", "platform:self-operated-analytics:export"), Map.entry("OVERSEAS_INVENTORY_BALANCE", "platform:overseas-warehouse:inventory:export"), Map.entry("OVERSEAS_INVENTORY_LEDGER", "platform:overseas-warehouse:inventory:export"), Map.entry("OVERSEAS_QUOTE_LIST", "platform:overseas-master-data:quote:export"), @@ -28,7 +29,7 @@ class LastlegExportDefinitionContractTest { assertEquals("/internal/exports/v1/" + entry.getKey() + "/batches", definition.get("provider-path")); assertEquals(entry.getValue(), definition.get("required-permission")); assertEquals("PLATFORM", definition.get("app-scope")); - assertEquals(200, definition.get("batch-size")); + assertEquals(entry.getKey().equals("SELFOP_INVENTORY_TURNOVER") ? 100 : 200, definition.get("batch-size")); var controlled = new ExportDefinitionRegistry.Definition(); controlled.setProviderService("lastleg-service"); controlled.setProviderPath(String.valueOf(definition.get("provider-path"))); diff --git a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java index de2d60427..c35fb8dc5 100644 --- a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java @@ -39,6 +39,23 @@ class ExportTaskServiceTest { @MockBean private ExportStateService states; @MockBean private ExportMetrics metrics; @Resource private ExportTaskService service; + @Test void turnoverKeepsBoundedSignedContextAndRequiresIndependentExportPermission() { + var definition=definition();definition.setRequiredPermission("platform:self-operated-analytics:export"); + when(registry.require("SELFOP_INVENTORY_TURNOVER")).thenReturn(definition); + when(states.createConstrained(any(FileExportTask.class))).thenAnswer(c->new ExportStateService.CreationResult(c.getArgument(0),true)); + Map parameters=new LinkedHashMap<>(); + parameters.put("sourceAccount","demo");parameters.put("warehouseCode","W");parameters.put("endDate","2026-10-14");parameters.put("baseWindowDays",30); + parameters.put("snapshotContext","x".repeat(15000));parameters.put("columns",List.of("sku","quantityTurnoverRate"));parameters.put("expectedTotal",205); + var request=new CreateExportTaskRequest("SELFOP_INVENTORY_TURNOVER","XLSX","库存周转",parameters); + assertThrows(BizException.class,()->service.create(user(1L,Set.of("platform:self-operated-analytics:list")),request)); + assertEquals("PENDING",service.create(user(1L,Set.of("platform:self-operated-analytics:export")),request).status()); + parameters.put("snapshotContext","x".repeat(16001)); + assertThrows(BizException.class,()->service.create(user(1L,Set.of("platform:self-operated-analytics:export")),request)); + parameters.put("snapshotContext","fixed");parameters.put("current",2); + assertThrows(BizException.class,()->service.create(user(1L,Set.of("platform:self-operated-analytics:export")),request)); + parameters.remove("current");parameters.remove("columns"); + assertThrows(BizException.class,()->service.create(user(1L,Set.of("platform:self-operated-analytics:export")),request)); + } @Test void createsPersistedTaskOnlyWhenSourceExportPermissionIsPresent() { when(registry.require("CUSTOMER_LIST")).thenReturn(definition()); @@ -94,6 +111,15 @@ class ExportTaskServiceTest { assertEquals("9", service.get(user(1L, Set.of("platform:download:all")), 9L).id()); } + @Test void turnoverDownloadAndRetryRecheckExportPermissionEvenWithDownloadAll() { + var task=new FileExportTask();task.setId(9L);task.setTenantId(0L);task.setAppCode("PLATFORM");task.setRequestedBy(2L); + task.setExportType("SELFOP_INVENTORY_TURNOVER");task.setStatus("SUCCEEDED");when(repository.find(9L)).thenReturn(task); + var reader=user(1L,Set.of("platform:download:all","platform:self-operated-analytics:list")); + assertEquals(ResultCode.FORBIDDEN,assertThrows(BizException.class,()->service.download(reader,9L)).getResultCode()); + assertEquals(ResultCode.FORBIDDEN,assertThrows(BizException.class,()->service.retry(reader,9L)).getResultCode()); + verifyNoInteractions(storage); + } + @Test void rejectsParametersDeeperThanTwoLevels() { when(registry.require("CUSTOMER_LIST")).thenReturn(definition()); diff --git a/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java b/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java index 50efcf033..815b76abe 100644 --- a/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java +++ b/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java @@ -66,6 +66,24 @@ class GenericXlsxExportRunnerTest { assertNull(requests.getAllValues().get(0).snapshotToken());assertEquals("snapshot-9",requests.getAllValues().get(1).snapshotToken()); } + @Test void turnoverWorkbookPreservesColumnOrderPercentUnknownAndLiteralSku() throws Exception { + var columns=List.of(new ExportColumn("quantityTurnoverRate","期间周转率",ExportColumnType.STRING,24), + new ExportColumn("sku","SKU",ExportColumnType.STRING,24),new ExportColumn("historyDays","合格历史库存天数",ExportColumnType.STRING,24)); + var batch=new ExportBatchResponse(1,"sha256:turnover","fixed",columns,List.of( + Map.of("quantityTurnoverRate","125%","sku","=SUM(1,2)","historyDays","30/30"), + Map.of("quantityTurnoverRate","—","sku","未知商品","historyDays","29/30"), + Map.of("quantityTurnoverRate","0%","sku","零销量","historyDays","30/30")),"3",false,3L); + when(provider.batch(anyString(),eq("SELFOP_INVENTORY_TURNOVER"),any(),any())).thenReturn(ApiResponse.success(batch)); + var task=new FileExportTask();task.setId(18L);task.setTenantId(0L);task.setAppCode("PLATFORM");task.setRequestedBy(7L);task.setDispatchVersion(1); + task.setExportType("SELFOP_INVENTORY_TURNOVER");task.setTaskName("库存周转");task.setParametersJson("{\"snapshotContext\":\"fixed\"}"); + var definition=new ExportDefinitionRegistry.Definition();definition.setMaxRows(200000);definition.setBatchSize(100); + var result=runner.run(task,definition,new AtomicBoolean(),ignored->{}); + try(var workbook=new XSSFWorkbook(storage.load(result.objectKey()).getInputStream())) { + var sheet=workbook.getSheetAt(0);assertEquals("期间周转率",sheet.getRow(0).getCell(0).getStringCellValue()); + assertEquals("125%",sheet.getRow(1).getCell(0).getStringCellValue());assertEquals("'=SUM(1,2)",sheet.getRow(1).getCell(1).getStringCellValue()); + assertEquals("—",sheet.getRow(2).getCell(0).getStringCellValue());assertEquals("0%",sheet.getRow(3).getCell(0).getStringCellValue());assertEquals(3,result.rows()); + } + } @Test void deletesTheCompletedFileWhenReadingItsFinalSizeFails() throws Exception { var batch=new ExportBatchResponse(1,"sha256:test","snapshot-9", List.of(new ExportColumn("text","文本",ExportColumnType.STRING,18)), diff --git a/iam/src/main/resources/db/selfoperated-analytics-permissions.sql b/iam/src/main/resources/db/selfoperated-analytics-permissions.sql index 46615ee1e..ab2a0d0fd 100644 --- a/iam/src/main/resources/db/selfoperated-analytics-permissions.sql +++ b/iam/src/main/resources/db/selfoperated-analytics-permissions.sql @@ -1,4 +1,4 @@ --- 自营业务菜单资源用于菜单管理;四个页面和所有只读接口统一由 list 授权,采集写权限独立。 +-- 自营业务菜单资源用于菜单管理;四个页面和所有只读接口统一由 list 授权,采集写权限及后台导出权限独立。 USE baiyue_iam; SET NAMES utf8mb4; INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES @@ -8,10 +8,12 @@ INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,perm (5118120,0,'PLATFORM','MENU',5118100,'platform:self-operated:inventory:menu','库存分析','/self-operated/inventory','GET',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), (5118130,0,'PLATFORM','MENU',5118100,'platform:self-operated:collection:menu','数据采集','/self-operated/collection','GET',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), (5118000,0,'PLATFORM','API',5118100,'platform:self-operated-analytics:list','读取平台自营出库销量与库存分析','/api/platform/v1/last-leg/self-operated-analytics','GET',40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118002,0,'PLATFORM','API',5118120,'platform:self-operated-analytics:export','导出平台自营库存周转','/api/platform/v1/files/export-tasks','POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), (5118001,0,'PLATFORM','API',5118130,'platform:self-operated-analytics:sync','平台自营采集重试与重采集','/api/platform/v1/last-leg/self-operated-analytics/sync-tasks','POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5118000,0,CURRENT_TIMESTAMP(3)), (4000001,5118001,0,CURRENT_TIMESTAMP(3)), + (4000001,5118002,0,CURRENT_TIMESTAMP(3)), (4000001,5118100,0,CURRENT_TIMESTAMP(3)), (4000001,5118140,0,CURRENT_TIMESTAMP(3)), (4000001,5118110,0,CURRENT_TIMESTAMP(3)), diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index ac273c171..0e811be40 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -19,7 +19,7 @@ test('selfoperated analytics is independent of customer outbound and inventory s assert.ok(classes.some(entry => entry.packageName.endsWith('.outbound'))); assert.ok(classes.every(entry => entry.slice === 'selfoperated-analytics')); assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'selfoperated-analytics' - || edge.target === 'selfoperated-analytics'), [{ source: 'selfoperated-analytics', target: 'shared-technical', count: 1 }]); + || edge.target === 'selfoperated-analytics'), [{ source: 'selfoperated-analytics', target: 'shared-technical', count: 1 }, { source: 'shared-technical', target: 'selfoperated-analytics', count: 1 }]); assert.deepEqual(report.classDependencies.filter(edge => edge.source.startsWith(prefix) && !edge.target.startsWith(prefix)), [{ source: prefix + 'request.SelfopEgressController', target: 'com.baiyue.lastleg.security.InternalServiceTokenGuard', }]); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportProviderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportProviderService.java index 53df619ac..3d3544477 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportProviderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportProviderService.java @@ -16,6 +16,7 @@ import com.baiyue.lastleg.fleet.quote.FleetQuoteModels; import com.baiyue.lastleg.fleet.quote.FleetQuoteService; import com.baiyue.lastleg.fleet.reporting.TripOperationsModels; import com.baiyue.lastleg.fleet.reporting.TripOperationsService; +import com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverExport; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -41,6 +42,7 @@ public class LastlegExportProviderService { @Resource private VehicleApplicationService vehicles; @Resource private FleetQuoteService fleetQuotes; @Resource private TripOperationsService operations; + @Resource private InventoryTurnoverExport turnover; @Resource private ObjectMapper json; @Resource private Validator validator; @Value("${baiyue.security.internal-service-token}") private String signingKey; @@ -51,15 +53,16 @@ public class LastlegExportProviderService { } if (request.taskId() == null || request.requestedBy() == null || request.dispatchVersion() < 1 || request.limit() < 1 || request.limit() > 200) throw invalid("导出批次参数无效"); - List columns; - try { columns = LastlegExportColumns.columns(type); } - catch (IllegalArgumentException e) { throw invalid("不支持该导出类型"); } int offset = cursor(request.cursor()); if (offset % request.limit() != 0) throw invalid("导出游标无效"); String snapshot = signature(type, request); boolean first = request.snapshotToken() == null; if ((first && offset != 0) || (!first && !MessageDigest.isEqual(snapshot.getBytes(StandardCharsets.UTF_8), request.snapshotToken().getBytes(StandardCharsets.UTF_8)))) throw invalid("导出筛选快照不匹配"); + if (InventoryTurnoverExport.TYPE.equals(type)) return turnover.batch(request, offset, snapshot); + List columns; + try { columns = LastlegExportColumns.columns(type); } + catch (IllegalArgumentException e) { throw invalid("不支持该导出类型"); } Map parameters = new TreeMap<>(request.parameters() == null ? Map.of() : request.parameters()); String customer = text(parameters.remove("customerId")); if (customer != null && !customer.matches("[1-9][0-9]*")) throw invalid("客户参数无效"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverExport.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverExport.java new file mode 100644 index 000000000..73be71e66 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverExport.java @@ -0,0 +1,133 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.common.core.export.ExportContracts.*; +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverResponses.*; + +/** Download-center projection: the browser supplies column keys, never headings or cell values. */ +@Service +public class InventoryTurnoverExport { + public static final String TYPE = "SELFOP_INVENTORY_TURNOVER"; + private final InventoryTurnoverQueryService queries; + private final ObjectMapper json; + public InventoryTurnoverExport(InventoryTurnoverQueryService queries, ObjectMapper json) { this.queries=queries; this.json=json; } + private static final Map LABELS = Map.ofEntries( + Map.entry("sku","SKU"), Map.entry("productName","商品名称"), Map.entry("customerCode","所属货主"), + Map.entry("physicalQuantity","实物件数"), Map.entry("availableQuantity","正品可售"), Map.entry("transitQuantity","在途"), + Map.entry("frozenQuantity","冻结件数"), Map.entry("defectiveQuantity","次品实物"), Map.entry("physicalSkuCount","有实物SKU数"), + Map.entry("inventoryVolumeM3","库存立方数"), Map.entry("outboundVolumesM3","出库立方数"), Map.entry("unitVolumeM3","单件体积"), + Map.entry("outbound7","7日出库"), Map.entry("outbound15","15日出库"), Map.entry("outbound30","30日出库"), + Map.entry("averageDailyOutboundQuantity","日均"), Map.entry("inventoryCoverageDays","预计可售天数"), + Map.entry("quantityTurnoverRate","期间周转率"), Map.entry("quantityTurnoverCount","期间件数周转"), + Map.entry("averagePhysicalQuantity","期间平均实物"), Map.entry("lastObservedOutboundDate","最近已观测出库日"), + Map.entry("noOutboundInWindow","无出库SKU / 实物"), Map.entry("slowCoverage","慢消化SKU / 待判断"), + Map.entry("period","统计期间"), Map.entry("periodOutbound","期间出库件数"), Map.entry("historySource","历史库存来源"), + Map.entry("historyDays","合格历史库存天数"), Map.entry("historyReason","历史库存说明")); + private static final List REQUIRED = List.of("period","periodOutbound","averagePhysicalQuantity","quantityTurnoverRate","historySource","historyDays","historyReason"); + + public ExportBatchResponse batch(ExportBatchRequest request, int offset, String signature) { + if(request.limit()>100) throw invalid("库存周转每批最多100条"); + Map parameters=new LinkedHashMap<>(request.parameters()==null?Map.of():request.parameters()); + Object rawColumns=parameters.remove("columns"), rawTotal=parameters.remove("expectedTotal"); + if(!(rawColumns instanceof List selected)||selected.isEmpty()||selected.size()>LABELS.size()) throw invalid("导出列无效"); + Set keys=new LinkedHashSet<>(); + for(Object key:selected) if(!(key instanceof String s)||!LABELS.containsKey(s)||!keys.add(s)) throw invalid("导出列重复或不支持"); + keys.addAll(REQUIRED); + long expected; + try { expected=new BigDecimal(String.valueOf(rawTotal)).longValueExact(); } + catch(RuntimeException e) { throw invalid("导出条数无效"); } + if(expected<0||expected>200000||offset>expected) throw invalid("导出条数超出范围"); + if(parameters.containsKey("current")||parameters.containsKey("size")||parameters.containsKey("includeOverview")) throw invalid("导出不接受列表分页参数"); + InventoryTurnoverRequest.validateParameters(parameters.keySet()); + parameters.put("current",offset/request.limit()+1);parameters.put("size",request.limit());parameters.put("includeOverview",false); + InventoryTurnoverRequest query; + try { query=json.convertValue(parameters,InventoryTurnoverRequest.class); } + catch(IllegalArgumentException e) { throw invalid("导出筛选无效"); } + if(query.snapshotContext()==null||query.snapshotContext().isBlank()||query.endDate()==null||query.baseWindowDays()==null) throw invalid("请先查询后导出"); + boolean owner="OWNER".equals(query.group()); + if(owner&&(keys.contains("sku")||keys.contains("productName")||keys.contains("unitVolumeM3")||keys.contains("inventoryCoverageDays")) + ||!owner&&(keys.contains("physicalSkuCount")||keys.contains("noOutboundInWindow")||keys.contains("slowCoverage"))) throw invalid("导出列不适用于当前分组"); + Response result=queries.query(query); + if(result.total()!=expected||!query.snapshotContext().equals(result.context().snapshotContext()) + ||result.rows().size()!=Math.min(request.limit(),expected-offset)) throw invalid("导出数据范围或版本已变化,请整体刷新后重新导出"); + Set> seen=new HashSet<>(); + for(Row row:result.rows()) if(!seen.add(Arrays.asList(row.customerCode(),row.sku()))) throw invalid("导出数据重复"); + List columns=keys.stream().map(key->new ExportColumn(key,title(key,query),ExportColumnType.STRING,24)).toList(); + List> rows=result.rows().stream().map(row->{ + Map values=new LinkedHashMap<>();for(String key:keys)values.put(key,cell(row,key,query));return values; + }).toList(); + String hash; + try { hash="sha256:"+HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(columns))); } + catch(Exception e) { throw new IllegalStateException("Cannot hash turnover export columns",e); } + long next=offset+rows.size(); + return new ExportBatchResponse(1,hash,signature,request.snapshotToken()==null?columns:List.of(),rows,String.valueOf(next),next text(r.sku()); case "productName" -> text(r.productName()); + case "customerCode" -> r.customerName()==null||r.customerName().isBlank()?text(r.customerCode()):r.customerName(); + case "physicalQuantity" -> total(r.physicalQuantity()); case "availableQuantity" -> total(r.availableQuantity()); + case "transitQuantity" -> total(r.transitQuantity()); case "frozenQuantity" -> total(r.frozenQuantity()); case "defectiveQuantity" -> total(r.defectiveQuantity()); + case "physicalSkuCount" -> number(r.physicalSkuCount(),2); + case "inventoryVolumeM3" -> volume(r.inventoryVolumeM3(),false); + case "outboundVolumesM3" -> volume(r.outboundVolumesM3().stream().filter(v->v.days()==q.days()).map(VolumeWindow::volume).findFirst().orElse(null),true); + case "unitVolumeM3" -> number(r.unitVolumeM3(),9)+(r.unitVolumeM3()==null?"":" m³")+";"+switch(text(r.dimensionSource())) { case "WAREHOUSE_MEASURED" -> "仓库实测尺寸";case "PRODUCT_DECLARED" -> "商品尺寸估算";default -> "缺少有效尺寸"; }; + case "outbound7","outbound15","outbound30" -> window(r,Integer.parseInt(key.substring(8))); + case "periodOutbound" -> window(r,q.days()); + case "averageDailyOutboundQuantity" -> number(r.averageDailyOutboundQuantity(),2); + case "inventoryCoverageDays" -> number(r.inventoryCoverageDays(),1)+";"+reason(r.coverageStatus()); + case "averagePhysicalQuantity" -> number(r.averagePhysicalQuantity(),2); + case "quantityTurnoverRate" -> r.quantityTurnoverCount()==null?"—":number(r.quantityTurnoverCount().multiply(BigDecimal.valueOf(100)),2)+"%"; + case "quantityTurnoverCount" -> r.quantityTurnoverCount()==null?"—":number(r.quantityTurnoverCount(),2)+" 次/"+q.days()+"日"; + case "period" -> "截至 "+q.endDate()+",最近 "+q.days()+" 天"; + case "historySource" -> "库存流水还原(日末实物库存)"; + case "historyDays" -> r.qualifiedSampleDays()+"/"+r.requiredSampleDays(); + case "historyReason" -> reasons(r.sampleReasonCodes()); + case "lastObservedOutboundDate" -> r.lastObservedOutboundDate()==null?"已采集历史内未见出库":r.lastObservedOutboundDate().toString(); + case "noOutboundInWindow" -> number(r.noOutboundInWindow().matchedSkuCount(),2)+" SKU / "+number(r.noOutboundInWindow().knownPhysicalQuantity(),2)+" 件;待判断 "+r.noOutboundInWindow().unknownSkuCount()+" SKU"; + case "slowCoverage" -> q.coverageDaysThreshold()==null?"未设查询阈值":number(r.slowCoverage().matchedSkuCount(),2)+" SKU / "+r.slowCoverage().unknownSkuCount()+" SKU"; + default -> throw invalid("不支持的导出列"); + }; + } + static String number(BigDecimal value,int decimals) { + if(value==null)return "—"; + BigDecimal threshold=BigDecimal.ONE.movePointLeft(decimals); + if(value.signum()>0&&value.compareTo(threshold)<0)return "<"+threshold.setScale(decimals).toPlainString(); + var f=new java.text.DecimalFormat("#,##0"+(decimals>0?"."+"#".repeat(decimals):""),java.text.DecimalFormatSymbols.getInstance(Locale.ROOT)); + f.setRoundingMode(RoundingMode.HALF_UP);return f.format(value); + } + private static String total(Total t) { + if(t.value()!=null)return number(t.value(),2); + if(t.knownSubtotal()==null&&t.unknownSkuCount()==0)return reasons(t.reasonCodes()); + return "已知 "+number(t.knownSubtotal(),2)+";"+t.unknownSkuCount()+" SKU待核验"; + } + private static String window(Row r,int days) { + return r.windows().stream().filter(w->w.days()==days).findFirst().map(w->"COMPLETE".equals(w.dataStatus())?number(w.outboundQuantity(),2):"已采集 "+number(w.observedQuantity(),2)+",覆盖不足").orElse("—"); + } + private static String volume(Volume v,boolean outbound) { + if(v==null)return "—;数量或尺寸待确认"; + List notes=new ArrayList<>(); + if(v.value()==null)notes.add(v.knownSubtotal()==null?"暂无已知小计":"已知小计 "+number(v.knownSubtotal(),6)+" m³"); + if(v.missingDimensionSkuCount()>0)notes.add("缺尺寸 "+v.missingDimensionSkuCount()+" SKU / "+number(v.missingDimensionQuantity(),2)+" 件"); + if(v.unknownQuantitySkuCount()>0)notes.add("数量待确认 "+v.unknownQuantitySkuCount()+" SKU"); + if(v.declaredDimensionQuantity()!=null&&v.declaredDimensionQuantity().signum()>0)notes.add("其中 "+number(v.declaredDimensionQuantity(),2)+" 件按商品尺寸估算"); + if(outbound)notes.add("按当前商品尺寸估算"); + else if(v.value()!=null&&v.value().signum()==0)notes.add("已知体积为0"); + else if(v.value()!=null&&(v.declaredDimensionQuantity()==null||v.declaredDimensionQuantity().signum()==0))notes.add("按仓库实测尺寸计算"); + return number(v.value(),6)+(v.value()==null?"":" m³")+";"+(notes.isEmpty()?"数量或尺寸待确认":String.join(";",notes)); + } + private static String reasons(List reasons){return reasons==null||reasons.isEmpty()?"—":String.join("、",reasons.stream().map(InventoryTurnoverExport::reason).toList());} + private static String reason(String reason){return TurnoverExportReasons.LABELS.getOrDefault(reason==null?"":reason,"数据待核验");} + private static String text(String value){return value==null||value.isBlank()?"—":value;} + private static BizException invalid(String message){return new BizException(message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/TurnoverExportReasons.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/TurnoverExportReasons.java new file mode 100644 index 000000000..f14d039fe --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/TurnoverExportReasons.java @@ -0,0 +1,154 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.util.Map; + +/** Business explanations retained in server-generated spreadsheets. */ +final class TurnoverExportReasons { + private TurnoverExportReasons() {} + static final Map LABELS = Map.ofEntries( + Map.entry("COMPLETE","完整"), + Map.entry("PARTIAL","未完整"), + Map.entry("UNKNOWN","未知"), + Map.entry("QUEUED","排队中"), + Map.entry("RUNNING","执行中"), + Map.entry("WAITING","等待中"), + Map.entry("WAITING_UPSTREAM","等待上游"), + Map.entry("WAITING_SOURCE","等待来源数据"), + Map.entry("RETRY_WAIT","待重试"), + Map.entry("YIELDED","预算等待"), + Map.entry("FAILED","失败"), + Map.entry("DEAD","待处理"), + Map.entry("SUCCEEDED","执行成功"), + Map.entry("ROUTED","账号共享采集完成"), + Map.entry("SAMPLE_DONE","测试样本结束"), + Map.entry("PUBLISHED","已发布"), + Map.entry("REUSED","沿用已有结果"), + Map.entry("NOT_PUBLISHED","未发布"), + Map.entry("PENDING","待处理"), + Map.entry("BLOCKED","修订阻断"), + Map.entry("OFF","关闭"), + Map.entry("VALIDATION","验证采样"), + Map.entry("BUSINESS","正式采集"), + Map.entry("FRESH","有效"), + Map.entry("STALE","过期"), + Map.entry("INVALID","异常"), + Map.entry("OBSERVING","观察中"), + Map.entry("HEALTHY","正常"), + Map.entry("STALE_HEARTBEAT","心跳过期,执行状态待确认"), + Map.entry("UNCONFIRMED","执行状态待确认"), + Map.entry("TERMINATED","尝试已结束"), + Map.entry("ABORTED","已停止"), + Map.entry("NOT_STARTED","尚未开始"), + Map.entry("FETCHING","采集中"), + Map.entry("FETCHED","采集完成"), + Map.entry("VALIDATING","校验中"), + Map.entry("BUILDING","计算中"), + Map.entry("READY","待发布"), + Map.entry("INITIALIZING","首采中"), + Map.entry("COVERED","覆盖完整"), + Map.entry("ACCOUNT_DISCOVERY","账号共享出库目录"), + Map.entry("WAREHOUSE_SYNC","本仓采集"), + Map.entry("VALID","有效"), + Map.entry("CURRENT","已核对"), + Map.entry("LAGGING","核对滞后"), + Map.entry("MISSING","尚无可用数据"), + Map.entry("UNCOVERED","覆盖不足"), + Map.entry("INCOMPLETE","未完整"), + Map.entry("REBUILD_REQUIRED","销量待重新统计发布"), + Map.entry("NO_AVAILABLE_INVENTORY","无可售库存"), + Map.entry("UNIT_UNKNOWN_OR_CONFLICT","数量单位未知或不一致"), + Map.entry("SALES_UNTRUSTED","销量待核对"), + Map.entry("COVERAGE_INSUFFICIENT","覆盖不足"), + Map.entry("OBSERVATION_INSUFFICIENT","观察期不足"), + Map.entry("SALES_UNKNOWN","销量未知"), + Map.entry("NO_OUTBOUND_IN_WINDOW","所选窗口无出库"), + Map.entry("NO_OUTBOUND_30_DAYS","近30天无出库"), + Map.entry("INVENTORY_INVALID","库存异常"), + Map.entry("INVENTORY_STALE","库存快照过期"), + Map.entry("INVENTORY_UNKNOWN","库存未知"), + Map.entry("UNASSESSABLE_SKUS","存在待评估SKU"), + Map.entry("INVENTORY_UNAVAILABLE","库存资料不可用"), + Map.entry("SALES_UNAVAILABLE","销量资料不可用"), + Map.entry("PHYSICAL_INVENTORY_UNKNOWN","实物库存待核验"), + Map.entry("AVAILABLE_INVENTORY_UNKNOWN","可售库存待核验"), + Map.entry("SALES_QUANTITY_OR_UNIT_INVALID","出库数量或单位异常"), + Map.entry("NO_ELIGIBLE_SKUS","暂无符合条件的SKU"), + Map.entry("NO_OUTBOUND_30D","近30天无出库"), + Map.entry("COVERAGE_GT_THRESHOLD","可售天数超过本次阈值"), + Map.entry("FROZEN_STOCK","存在冻结库存"), + Map.entry("DEFECTIVE_STOCK","存在次品实物库存"), + Map.entry("UNASSESSABLE","数据不足/待观察"), + Map.entry("THRESHOLD_NOT_SET","未设置查询阈值"), + Map.entry("NO_PHYSICAL_INVENTORY","无实物库存"), + Map.entry("METRIC_DEPENDENCY_UNKNOWN","该指标依赖资料未知"), + Map.entry("SALES_WINDOW_UNAVAILABLE","同期完整销量不足"), + Map.entry("SALES_WINDOW_INCOMPLETE","所选时段销量尚未采齐"), + Map.entry("RISK_DEPENDENCY_UNKNOWN","部分SKU关注判断未知"), + Map.entry("NO_OUTBOUND_IN_COLLECTED_HISTORY","已采集历史内未见出库"), + Map.entry("SALES_NOT_COVERED","销量未覆盖"), + Map.entry("SKU_HISTORY_UNKNOWN","SKU历史未知"), + Map.entry("QUANTITY_UNIT_UNKNOWN","数量单位未知"), + Map.entry("INVENTORY_NOT_PUBLISHED","库存尚未发布"), + Map.entry("SALES_NOT_PUBLISHED","销量尚未发布"), + Map.entry("OBSERVATION_INCOMPLETE","观察期不足"), + Map.entry("SCOPE_CHANGED","配置范围已变化"), + Map.entry("UPSTREAM_NOT_READY","账号共享上游未就绪"), + Map.entry("TASK_NOT_RETRYABLE","任务当前不可恢复"), + Map.entry("COLLECTION_MODE_NOT_BUSINESS","当前不是正式采集模式"), + Map.entry("VALIDATION_NOT_RETRYABLE","验证会话不可恢复"), + Map.entry("ACCOUNT_SHARED_TASK","账号共享任务由协调器恢复"), + Map.entry("SOURCE_TIMEZONE_UNKNOWN","来源时区未确认"), + Map.entry("SALES_DAY_INCOMPLETE","销量日期未完整"), + Map.entry("SALES_NOT_COVERED_OR_SKU_HISTORY_UNKNOWN","销量未覆盖或SKU历史未知"), + Map.entry("SCOPE_LEASE_BUSY","本仓任务正在执行"), + Map.entry("COLLECTION_OFF","采集模式关闭"), + Map.entry("COLLECTION_MODE_INVALID","采集模式配置无效"), + Map.entry("BUSINESS_ENVIRONMENT_REJECTED","正式采集只允许生产环境"), + Map.entry("EXECUTION_DISABLED","采集执行未启用"), + Map.entry("WAREHOUSE_NOT_CONFIGURED","仓库未配置"), + Map.entry("TIMEZONE_OR_UNIT_NOT_CONFIRMED","时间或件数口径配置不符合确认值"), + Map.entry("ACCOUNT_PAUSED","账号采集已暂停"), + Map.entry("ACCOUNT_COOLDOWN","账号处于冷却期"), + Map.entry("EXCLUSIVE_GRANT_NOT_VALID","账号独占窗口授权无效"), + Map.entry("DAILY_BUDGET_NOT_CONFIGURED","账号日额度未配置"), + Map.entry("DAILY_BUDGET_EXHAUSTED","账号日额度已用完"), + Map.entry("SALES_MISSING","尚无销量发布版本"), + Map.entry("INVENTORY_MISSING","尚无库存发布版本"), + Map.entry("SALES_BLOCKED","销量修订阻断"), + Map.entry("SALES_STALE","销量数据过期"), + Map.entry("REVISION_REVIEW_UNKNOWN","修订核对状态未知"), + Map.entry("REVISION_REVIEW_LAGGING","修订核对滞后"), + Map.entry("SALES_REBUILD_REQUIRED","销量有变动,待重新统计发布"), + Map.entry("TODAY_INCOMPLETE","今日尚未结束,不能作为完整自然日"), + Map.entry("SALES_SOURCE_TIMEZONE_UNKNOWN","销量来源时区未确认"), + Map.entry("INDEPENDENT_ANCHOR_MISSING","缺少独立库存对账记录"), + Map.entry("INDEPENDENT_ANCHOR_QUALITY_MISSING","独立库存品质记录不完整"), + Map.entry("INDEPENDENT_ANCHOR_INVALID","独立库存记录无效"), + Map.entry("INDEPENDENT_ANCHOR_COVERAGE_MISSING","库存对账区间流水未齐"), + Map.entry("INDEPENDENT_ANCHOR_MISMATCH","流水与独立库存记录不符"), + Map.entry("INDEPENDENT_ANCHOR_FLOW_UNKNOWN","库存对账流水待核验"), + Map.entry("ANCHOR_TIME_AMBIGUOUS","采集库存时发生变动,待稳定库存记录核验"), + Map.entry("ANCHOR_MISSING","缺少库存锚点"), + Map.entry("FLOW_COVERAGE_GAP","库存流水覆盖不完整"), + Map.entry("FLOW_TYPE_UNVERIFIED","流水类型尚未核验"), + Map.entry("CHAIN_DISCONTINUITY","批次结存不连续"), + Map.entry("CHAIN_ORDER_AMBIGUOUS","同秒流水顺序待核验"), + Map.entry("FLOW_QUANTITY_OR_TIME_INVALID","流水数量或时间待核验"), + Map.entry("FLOW_QUANTITY_CONFLICT","流水变动数量不一致"), + Map.entry("NEGATIVE_CHAIN_STOCK","批次结存出现负数"), + Map.entry("NEGATIVE_RECONSTRUCTED_STOCK","还原库存出现负数"), + Map.entry("UNIT_UNKNOWN","库存数量单位待确认"), + Map.entry("ANCHOR_DUPLICATE","库存锚点重复"), + Map.entry("ANCHOR_QUANTITY_INVALID","库存锚点数量无效"), + Map.entry("ANCHOR_INTERVAL_INVALID","库存观测时间无效"), + Map.entry("ANCHOR_BEFORE_DAY_END","库存锚点早于日末"), + Map.entry("FLOW_ID_INVALID","流水标识无效"), + Map.entry("FLOW_ID_CONFLICT","流水标识存在冲突"), + Map.entry("UNKNOWN_QUALITY","库存品质待确认"), + Map.entry("CHAIN_IDENTITY_MISSING","流水库位或批次缺失"), + Map.entry("INSUFFICIENT","合格日末库存记录不足"), + Map.entry("ZERO_AVERAGE_STOCK","期间平均日末库存为0,无法计算周转"), + Map.entry("INVENTORY_UNKNOWN_OR_STALE","当前库存未知、异常或已过期"), + Map.entry("NOT_APPLICABLE","货主不计算整体可售天数"), + Map.entry("SALES_OR_STOCK_UNASSESSABLE","部分商品的销量或库存不足以判断")); +} diff --git a/lastleg-service/src/main/resources/openapi/lastleg-exports-v1.yaml b/lastleg-service/src/main/resources/openapi/lastleg-exports-v1.yaml index 47152332a..b5c7226a7 100644 --- a/lastleg-service/src/main/resources/openapi/lastleg-exports-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/lastleg-exports-v1.yaml @@ -10,6 +10,9 @@ info: 每批最多 200 行,总行数上限 200000;车队报价及出车记录保留业务查询的 5000 条限制。 snapshotToken 绑定任务、申请人、执行版本和筛选条件,不表示数据库历史快照。 TRIP_OPERATIONS_LIST 的 costVisible 仅由 File 根据申请人的 platform:fleet:trip:list 权限写入,不能信任客户端值。 + SELFOP_INVENTORY_TURNOVER 每批最多100行;parameters 必须提供库存周转列表的 snapshotContext、expectedTotal 和有序 columns(仅列键)。 + 它沿用 inventory-turnover 的筛选、排序、固定版本及30分钟有效期,拒绝 current/size/includeOverview;版本失效或条数变化整任务失败,不切换到最新数据。 + columns 由服务端白名单解释并追加统计期间、期间出库、平均日末库存、周转率和历史证据;列头与说明由服务端生成。 原 Lastleg 业务 /export CSV 公网接口已移除。文件领取、过期、失败重试由 File 下载中心管理。 paths: /internal/exports/v1/{exportType}/batches: @@ -25,7 +28,7 @@ paths: required: true schema: type: string - enum: [LAST_MILE_ORDER_LIST, OVERSEAS_INVENTORY_BALANCE, OVERSEAS_INVENTORY_LEDGER, OVERSEAS_QUOTE_LIST, MONTHLY_EXCHANGE_RATE_LIST, VEHICLE_LIST, FLEET_QUOTE_LIST, TRIP_OPERATIONS_LIST] + enum: [LAST_MILE_ORDER_LIST, OVERSEAS_INVENTORY_BALANCE, OVERSEAS_INVENTORY_LEDGER, OVERSEAS_QUOTE_LIST, MONTHLY_EXCHANGE_RATE_LIST, VEHICLE_LIST, FLEET_QUOTE_LIST, TRIP_OPERATIONS_LIST, SELFOP_INVENTORY_TURNOVER] requestBody: required: true content: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverExportTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverExportTest.java new file mode 100644 index 000000000..a3171e35d --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverExportTest.java @@ -0,0 +1,84 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.common.core.export.ExportContracts.*; +import com.baiyue.lastleg.export.LastlegExportProviderService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; +import java.math.BigDecimal; +import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverResponses.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class InventoryTurnoverExportTest { + final ObjectMapper json=new ObjectMapper().findAndRegisterModules(); + final InventoryTurnoverQueryService queries=mock(InventoryTurnoverQueryService.class); + final InventoryTurnoverExport export=new InventoryTurnoverExport(queries,json); + Map parameters() { + Map p=new LinkedHashMap<>(); + p.put("sourceAccount","demo");p.put("warehouseCode","W");p.put("endDate","2026-10-14");p.put("baseWindowDays",30); + p.put("groupBy","SKU");p.put("customerCodes",List.of("A","B"));p.put("sortBy","TURNOVER_DESC");p.put("hideZeroInventory",true); + p.put("snapshotContext","fixed");p.put("expectedTotal",205);p.put("columns",List.of("quantityTurnoverRate","sku","inventoryVolumeM3"));return p; + } + ExportBatchRequest request(Map p,String cursor,String token) {return new ExportBatchRequest("task","0","PLATFORM","user",1,p,cursor,token,100);} + Row row(int i) { + Map r=new LinkedHashMap<>();r.put("sku",i==0?"=SUM(1,2)":"S"+i);r.put("customerCode","A");r.put("customerName","货主甲"); + r.put("quantityTurnoverCount",i==1?null:i==2?0:new BigDecimal("1.25"));r.put("averagePhysicalQuantity",new BigDecimal("80")); + r.put("windows",List.of(Map.of("days",30,"outboundQuantity",100,"dataStatus","COMPLETE"))); + r.put("qualifiedSampleDays",30);r.put("requiredSampleDays",30);r.put("sampleReasonCodes",List.of()); + r.put("inventoryVolumeM3",Map.of("knownSubtotal",1,"missingDimensionSkuCount",2,"missingDimensionQuantity",3,"declaredDimensionQuantity",4)); + return json.convertValue(r,Row.class); + } + Response response(InventoryTurnoverRequest q,long total,String token) { + Context c=mock(Context.class);when(c.snapshotContext()).thenReturn(token); + List rows=java.util.stream.IntStream.range((int)q.offset(),(int)Math.min(total,q.offset()+q.pageSize())).mapToObj(this::row).toList(); + return new Response(c,null,null,rows,total); + } + LastlegExportProviderService provider() { + var p=new LastlegExportProviderService();ReflectionTestUtils.setField(p,"json",json);ReflectionTestUtils.setField(p,"turnover",export); + ReflectionTestUtils.setField(p,"signingKey","test-export-signing-key-with-32-bytes");return p; + } + @Test void batchesAllRowsWithVisibleColumnOrderPercentageNotesAndFrozenFilters() { + when(queries.query(any())).thenAnswer(c->response(c.getArgument(0),205,"fixed")); + var p=provider();var first=p.batch(InventoryTurnoverExport.TYPE,request(parameters(),null,null)); + assertEquals(List.of("quantityTurnoverRate","sku","inventoryVolumeM3"),first.columns().subList(0,3).stream().map(ExportColumn::key).toList()); + assertEquals("125%",first.rows().get(0).get("quantityTurnoverRate"));assertEquals("—",first.rows().get(1).get("quantityTurnoverRate"));assertEquals("0%",first.rows().get(2).get("quantityTurnoverRate")); + assertTrue(first.rows().get(0).get("inventoryVolumeM3").toString().contains("缺尺寸 2 SKU / 3 件")); + assertEquals("库存流水还原(日末实物库存)",first.rows().get(0).get("historySource")); + var second=p.batch(InventoryTurnoverExport.TYPE,request(parameters(),first.nextCursor(),first.snapshotToken())); + var third=p.batch(InventoryTurnoverExport.TYPE,request(parameters(),second.nextCursor(),second.snapshotToken())); + assertEquals(205,first.rows().size()+second.rows().size()+third.rows().size());assertFalse(third.hasMore());assertTrue(second.columns().isEmpty());assertEquals(first.schemaHash(),third.schemaHash()); + verify(queries).query(argThat(q->q.page()==2&&q.pageSize()==100&&!q.overview()&&q.hideZero()&&q.customerCodes().equals(List.of("A","B"))&&q.sort().equals("TURNOVER_DESC")&&q.snapshotContext().equals("fixed"))); + var changed=parameters();changed.put("columns",List.of("sku")); + assertThrows(ResponseStatusException.class,()->p.batch(InventoryTurnoverExport.TYPE,request(changed,"100",first.snapshotToken()))); + assertThrows(ResponseStatusException.class,()->p.batch(InventoryTurnoverExport.TYPE,new ExportBatchRequest("task","0","PLATFORM","other",1,parameters(),"100",first.snapshotToken(),100))); + assertThrows(ResponseStatusException.class,()->p.batch(InventoryTurnoverExport.TYPE,new ExportBatchRequest("task","2","CLIENT","user",1,parameters(),null,null,100))); + } + @Test void rejectsStaleOrChangedDataWithoutReturningPartialFileAndSupportsEmptyResults() { + doAnswer(c->response(c.getArgument(0),204,"fixed")).when(queries).query(any()); + assertThrows(com.baiyue.common.core.exception.BizException.class,()->export.batch(request(parameters(),null,null),0,"signed")); + doAnswer(c->response(c.getArgument(0),205,"other")).when(queries).query(any()); + assertThrows(com.baiyue.common.core.exception.BizException.class,()->export.batch(request(parameters(),null,null),0,"signed")); + var empty=parameters();empty.put("expectedTotal",0);doAnswer(c->response(c.getArgument(0),0,"fixed")).when(queries).query(any()); + var result=export.batch(request(empty,null,null),0,"signed");assertFalse(result.hasMore());assertEquals(0L,result.totalRows());assertFalse(result.columns().isEmpty()); + doThrow(new com.baiyue.common.core.exception.BizException("库存周转上下文无效或已过期,请整体刷新")).when(queries).query(any()); + assertThrows(com.baiyue.common.core.exception.BizException.class,()->export.batch(request(parameters(),null,null),0,"signed")); + } + @Test void validatesColumnsPaginationAndSnapshotBeforeQuerying() { + for(var columns:List.of(List.of("internalId"),List.of("sku","sku"))) { + var p=parameters();p.put("columns",columns);assertThrows(com.baiyue.common.core.exception.BizException.class,()->export.batch(request(p,null,null),0,"s")); + } + var owner=parameters();owner.put("groupBy","OWNER");assertThrows(com.baiyue.common.core.exception.BizException.class,()->export.batch(request(owner,null,null),0,"s")); + var noContext=parameters();noContext.remove("snapshotContext");assertThrows(com.baiyue.common.core.exception.BizException.class,()->export.batch(request(noContext,null,null),0,"s")); + var paged=parameters();paged.put("current",1);assertThrows(com.baiyue.common.core.exception.BizException.class,()->export.batch(request(paged,null,null),0,"s")); + verifyNoInteractions(queries); + assertEquals("<0.01",InventoryTurnoverExport.number(new BigDecimal("0.00001"),2)); + } + @Test void ownerExportUsesTheServerAggregateWithoutAveragingSkuPercentages() { + var p=parameters();p.put("groupBy","OWNER");p.put("columns",List.of("customerCode","quantityTurnoverRate"));p.put("expectedTotal",1); + when(queries.query(any())).thenAnswer(c->response(c.getArgument(0),1,"fixed")); + var result=export.batch(request(p,null,null),0,"signed");assertEquals("货主甲",result.rows().get(0).get("customerCode"));assertEquals("125%",result.rows().get(0).get("quantityTurnoverRate")); + } +} diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json index 53a2a0df1..1da8010e0 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -129,6 +129,11 @@ "target": "com.baiyue.lastleg.security.InternalServiceTokenGuard", "reason": "内部出口许可端点复用现行服务令牌校验,不复制认证实现;只允许该控制器依赖该基础校验器,不开放客户业务或共享技术切片的其他引用。" }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverExport", + "reason": "统一导出装配器只调用自营周转的公开导出投影,复用固定版本查询;不开放自营Mapper或客户WMS库存访问,不产生反向依赖。" + }, { "source": "com.baiyue.lastleg.financefee.fleet.VehicleCareFinanceAdapter", "target": "com.baiyue.lastleg.fleet.maintenance.VehicleCareFinanceGateway",