生产更新:同步已提交的海外仓分析及业务修复(2026-10-16) #27

Merged
admin merged 138 commits from test into main 2026-10-16 18:02:59 +08:00
18 changed files with 546 additions and 9 deletions
Showing only changes of commit dbb93a06c2 - Show all commits
+9
View File
@@ -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
+2
View File
@@ -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同名配置同步);无业务建表及采集配置变更。执行前须逐项授权。
@@ -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;
@@ -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)),
@@ -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)),
@@ -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)),
@@ -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',
}]);
@@ -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<ExportColumn> 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<ExportColumn> columns;
try { columns = LastlegExportColumns.columns(type); }
catch (IllegalArgumentException e) { throw invalid("不支持该导出类型"); }
Map<String, Object> 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("客户参数无效");
@@ -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<String,String> 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<String> 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<String,Object> 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<String> 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<List<String>> seen=new HashSet<>();
for(Row row:result.rows()) if(!seen.add(Arrays.asList(row.customerCode(),row.sku()))) throw invalid("导出数据重复");
List<ExportColumn> columns=keys.stream().map(key->new ExportColumn(key,title(key,query),ExportColumnType.STRING,24)).toList();
List<Map<String,Object>> rows=result.rows().stream().map(row->{
Map<String,Object> 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<expected,request.snapshotToken()==null?expected:null);
}
private static String title(String key,InventoryTurnoverRequest q) {
if(key.equals("outboundVolumesM3"))return q.days()+"日出库立方数";
if(key.equals("averageDailyOutboundQuantity"))return q.days()+"日日均";
return LABELS.get(key);
}
static String cell(Row r,String key,InventoryTurnoverRequest q) {
return switch(key) {
case "sku" -> 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<String> 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<String> 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);}
}
@@ -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<String,String> 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","部分商品的销量或库存不足以判断"));
}
@@ -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:
@@ -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"));
}
}
@@ -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",