feat(selfop): provide frozen turnover batches for backend Excel exports
This commit is contained in:
@@ -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"))) {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user