生产更新:同步已提交的海外仓分析及业务修复(2026-10-16) #27
@@ -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
|
||||
|
||||
@@ -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"))) {
|
||||
|
||||
@@ -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',
|
||||
}]);
|
||||
|
||||
+6
-3
@@ -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("客户参数无效");
|
||||
|
||||
+133
@@ -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);}
|
||||
}
|
||||
+154
@@ -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:
|
||||
|
||||
+84
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user