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
@@ -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<String, Object> parameters) {
if (TURNOVER.equals(type)) {
Map<String,Object> 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<String, Object> values = parameters == null ? Map.of() : parameters;
if (!values.keySet().equals(java.util.Set.of("loadingPlanId"))) {
+9
View File
@@ -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
@@ -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}
@@ -13,6 +13,7 @@ class LastlegExportDefinitionContractTest {
Map<?, ?> nacos = definitions(Path.of("../config/nacos/test/file-service.yml"));
Map<String, String> 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")));
@@ -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<String,Object> 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());
@@ -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)),