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] 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",