feat(selfop): provide frozen turnover batches for backend Excel exports

This commit is contained in:
2026-10-15 15:00:03 +08:00
parent 22d79e08f9
commit dbb93a06c2
18 changed files with 546 additions and 9 deletions
@@ -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<String,Object> parameters() {
Map<String,Object> 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<String,Object> p,String cursor,String token) {return new ExportBatchRequest("task","0","PLATFORM","user",1,p,cursor,token,100);}
Row row(int i) {
Map<String,Object> 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<Row> 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"));
}
}