Merge remote-tracking branch 'origin/test' into dev

This commit is contained in:
2026-09-01 21:51:52 +08:00
42 changed files with 1598 additions and 67 deletions
+10
View File
@@ -22,6 +22,16 @@ baiyue:
max-attempts: 3
batch-size: 500
DOMESTIC_WAREHOUSE_ORDER_LIST:
provider-service: order-service
provider-path: /internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches
required-permission: platform:order:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
LOADING_LIST_BUY_DECLARATION_ITEMS:
provider-service: firstleg-service
provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches
+10
View File
@@ -18,6 +18,16 @@ baiyue:
max-attempts: 3
batch-size: 500
DOMESTIC_WAREHOUSE_ORDER_LIST:
provider-service: order-service
provider-path: /internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches
required-permission: platform:order:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
LOADING_LIST_BUY_DECLARATION_ITEMS:
provider-service: firstleg-service
provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches
@@ -7,6 +7,7 @@ import java.util.List;
final class ExportSchemaContracts {
static final String BUY_DECLARATION_ITEMS = "LOADING_LIST_BUY_DECLARATION_ITEMS";
static final String DOMESTIC_WAREHOUSE_ORDER_LIST = "DOMESTIC_WAREHOUSE_ORDER_LIST";
static final List<ExpectedColumn> BUY_DECLARATION_COLUMNS = List.of(
column("customerName", "客户名称", ExportColumnType.STRING),
column("orderNo", "订单号", ExportColumnType.STRING),
@@ -21,20 +22,54 @@ final class ExportSchemaContracts {
column("volume", "体积", ExportColumnType.DECIMAL),
column("mark", "唛头", ExportColumnType.STRING),
column("customerRemark", "客户备注", ExportColumnType.STRING));
static final List<ExpectedColumn> DOMESTIC_WAREHOUSE_ORDER_COLUMNS = List.of(
column("inboundCode", "入仓码", ExportColumnType.STRING),
column("orderNo", "订单号", ExportColumnType.STRING),
column("mainStatus", "订单主状态", ExportColumnType.STRING),
column("customerName", "客户", ExportColumnType.STRING),
column("channelName", "渠道", ExportColumnType.STRING),
column("channelChanged", "曾变更渠道", ExportColumnType.BOOLEAN),
column("receivingWarehouseName", "定位仓库", ExportColumnType.STRING),
column("destinationWarehouseName", "交货仓", ExportColumnType.STRING),
column("expectedShipDate", "预计出运日期", ExportColumnType.DATE),
column("expectedArrivalAt", "预计到货时间", ExportColumnType.DATETIME),
column("expectedVolumeCbm", "预计体积(m³)", ExportColumnType.DECIMAL),
column("totalPackageCount", "箱数", ExportColumnType.INTEGER),
column("totalQuantity", "产品数量", ExportColumnType.INTEGER),
column("actualGrossWeightKg", "实测毛重(kg)", ExportColumnType.DECIMAL),
column("actualVolumeCbm", "实测体积(m³)", ExportColumnType.DECIMAL),
column("chargeableWeightKg", "计费重量(kg)", ExportColumnType.DECIMAL),
column("chargeableVolumeCbm", "计费体积(m³)", ExportColumnType.DECIMAL),
column("feeReviewStatus", "费用审核状态", ExportColumnType.STRING),
column("skuNos", "SKU", ExportColumnType.STRING),
column("taxMode", "报关方式", ExportColumnType.STRING),
column("containerMode", "装柜方式", ExportColumnType.STRING),
column("customsInspection", "是否商检", ExportColumnType.BOOLEAN),
column("containerNo", "集装箱号", ExportColumnType.STRING),
column("cabinetNo", "柜次号", ExportColumnType.STRING),
column("vesselName", "船名", ExportColumnType.STRING),
column("voyageNo", "航次", ExportColumnType.STRING),
column("createdAt", "创建时间", ExportColumnType.DATETIME));
private ExportSchemaContracts() { }
static void validate(String exportType, List<ExportColumn> columns) {
if (!BUY_DECLARATION_ITEMS.equals(exportType)) return;
if (columns.size() != BUY_DECLARATION_COLUMNS.size()) {
throw ExportFailure.permanent("买单报关导出列定义不匹配");
if (BUY_DECLARATION_ITEMS.equals(exportType)) {
validate(columns, BUY_DECLARATION_COLUMNS, "买单报关导出列定义不匹配");
} else if (DOMESTIC_WAREHOUSE_ORDER_LIST.equals(exportType)) {
validate(columns, DOMESTIC_WAREHOUSE_ORDER_COLUMNS, "国内仓订单导出列定义不匹配");
}
}
private static void validate(List<ExportColumn> columns, List<ExpectedColumn> expectedColumns,
String failureMessage) {
if (columns.size() != expectedColumns.size()) throw ExportFailure.permanent(failureMessage);
for (int index = 0; index < columns.size(); index++) {
ExportColumn actual = columns.get(index);
ExpectedColumn expected = BUY_DECLARATION_COLUMNS.get(index);
ExpectedColumn expected = expectedColumns.get(index);
if (!expected.key.equals(actual.key()) || !expected.title.equals(actual.title())
|| expected.type != actual.type()) {
throw ExportFailure.permanent("买单报关导出列定义不匹配");
throw ExportFailure.permanent(failureMessage);
}
}
}
@@ -44,7 +44,7 @@ public class ExportTaskService {
if (!user.permissions().contains(permission)) {
throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限");
}
validateParameters(request.parameters());
validateParameters(type, request.parameters());
validateTypeParameters(type, request.parameters());
String parametersJson = parametersJson(request.parameters());
long id = IdWorker.getId();
@@ -140,15 +140,23 @@ public class ExportTaskService {
catch (JsonProcessingException exception) { throw new BizException(ResultCode.BAD_REQUEST, "导出筛选条件无法序列化"); }
}
private void validateParameters(Map<String, Object> parameters) {
private void validateParameters(String type, Map<String, Object> parameters) {
Map<String, Object> values = parameters == null ? Map.of() : parameters;
if (values.size() > 20) throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选条件最多 20 项");
int maxItems = ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST.equals(type) ? 32 : 20;
if (values.size() > maxItems) {
throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选条件最多 " + maxItems + " 项");
}
values.forEach((key, value) -> {
if (key == null || key.isBlank() || key.length() > 64) throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选键无效");
validateParameterValue(value, 1);
});
try {
if (objectMapper.writeValueAsBytes(values).length > 4096) throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选条件不能超过 4KB");
int maxBytes = ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST.equals(type)
? 512 * 1024 : 4096;
if (objectMapper.writeValueAsBytes(values).length > maxBytes) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"导出筛选条件不能超过 " + (maxBytes / 1024) + "KB");
}
} catch (JsonProcessingException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "导出筛选条件无法序列化");
}
+9
View File
@@ -101,6 +101,15 @@ baiyue:
max-rows: 200000
max-attempts: 3
batch-size: 500
DOMESTIC_WAREHOUSE_ORDER_LIST:
provider-service: order-service
provider-path: /internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches
required-permission: platform:order:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
LOADING_LIST_BUY_DECLARATION_ITEMS:
provider-service: firstleg-service
provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches
@@ -0,0 +1,109 @@
openapi: 3.0.3
info:
title: File Domestic Warehouse Order Export Contract
version: 1.0.0
description: >-
File 只持有异步任务、XLSX 文件、任务归属和下载权限。订单筛选、数据权限、分页游标及行数据由
Order 的 DOMESTIC_WAREHOUSE_ORDER_LIST Provider 持有,File 不复制订单业务查询。
paths:
/api/platform/v1/files/export-tasks:
post:
summary: 创建国内仓订单 XLSX 导出任务
description: >-
exportType 固定 DOMESTIC_WAREHOUSE_ORDER_LIST,format 固定 XLSX,调用人必须具有
platform:order:export。selectedOrderNos 非空时导出勾选业务订单号;为空或省略时由 Order
按 parameters 中当前列表筛选导出全部匹配记录,parameters 不接受 page、size 或内部数据库 ID。
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CreateDomesticOrderExportTask'}
responses:
'200': {description: 返回异步任务,初始状态 PENDING}
'403': {description: 缺少 platform:order:export}
/api/platform/v1/files/export-tasks/{taskId}/download:
get:
summary: 下载本人已完成的订单导出文件
parameters:
- {name: taskId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200':
description: XLSX 文件;他人任务仅 platform:download:all 可下载
headers:
Cache-Control: {schema: {type: string, example: no-store}}
Content-Disposition: {schema: {type: string, example: attachment}}
content:
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
schema: {type: string, format: binary}
components:
schemas:
CreateDomesticOrderExportTask:
type: object
required: [exportType, format, taskName, parameters]
properties:
exportType: {type: string, enum: [DOMESTIC_WAREHOUSE_ORDER_LIST]}
format: {type: string, enum: [XLSX]}
taskName: {type: string, minLength: 1, maxLength: 128}
parameters: {$ref: '#/components/schemas/DomesticOrderExportParameters'}
DomesticOrderExportParameters:
type: object
description: >-
透传给 Order Provider;selectedOrderNos 非空时优先,其余字段与平台订单列表当前筛选一致。
properties:
selectedOrderNos:
type: array
maxItems: 5000
uniqueItems: true
items: {type: string, minLength: 1, maxLength: 64, description: 订单业务编号}
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
keyword: {type: string, maxLength: 64}
orderNo: {type: string, maxLength: 64}
mainStatus: {type: string}
customerId: {type: string, pattern: '^[1-9][0-9]*$'}
receivingWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
inboundCode: {type: string, maxLength: 64}
internalOrderStatus: {type: string, maxLength: 32}
expectedArrivalDate: {type: string, format: date}
feeReviewStatus: {type: string}
channelName: {type: string, maxLength: 128}
containerNo: {type: string, maxLength: 64}
cabinetNo: {type: string, maxLength: 64}
sailingDate: {type: string, format: date}
loaded: {type: boolean}
fullContainer: {type: boolean}
measurementReviewStatus: {type: string}
fbaCode: {type: string, maxLength: 64}
createdFrom: {type: string, format: date-time}
createdTo: {type: string, format: date-time}
DomesticOrderExportColumns:
type: object
description: Provider schemaVersion=1 固定列顺序;不包含任何内部数据库 ID。
properties:
inboundCode: {type: string, title: 入仓码}
orderNo: {type: string, title: 订单号}
mainStatus: {type: string, title: 订单主状态}
customerName: {type: string, title: 客户}
channelName: {type: string, title: 渠道}
channelChanged: {type: boolean, title: 曾变更渠道}
receivingWarehouseName: {type: string, title: 定位仓库}
destinationWarehouseName: {type: string, title: 交货仓}
expectedShipDate: {type: string, format: date, title: 预计出运日期}
expectedArrivalAt: {type: string, format: date-time, title: 预计到货时间}
expectedVolumeCbm: {type: number, title: 预计体积(m³)}
totalPackageCount: {type: integer, title: 箱数}
totalQuantity: {type: integer, title: 产品数量}
actualGrossWeightKg: {type: number, title: 实测毛重(kg)}
actualVolumeCbm: {type: number, title: 实测体积(m³)}
chargeableWeightKg: {type: number, title: 计费重量(kg)}
chargeableVolumeCbm: {type: number, title: 计费体积(m³)}
feeReviewStatus: {type: string, title: 费用审核状态}
skuNos: {type: string, title: SKU}
taxMode: {type: string, title: 报关方式}
containerMode: {type: string, title: 装柜方式}
customsInspection: {type: boolean, title: 是否商检}
containerNo: {type: string, title: 集装箱号}
cabinetNo: {type: string, title: 柜次号}
vesselName: {type: string, title: 船名}
voyageNo: {type: string, title: 航次}
createdAt: {type: string, format: date-time, title: 创建时间}
@@ -0,0 +1,60 @@
package com.baiyue.file.config;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class DomesticWarehouseOrderExportContractTest {
private static final String TYPE = "DOMESTIC_WAREHOUSE_ORDER_LIST";
@Test void applicationAndNacosRegisterTheSameOrderProvider() throws Exception {
for (String file : List.of("file/src/main/resources/application.yml",
"config/nacos/local/file-service.yml", "config/nacos/cn/file-service.yml")) {
String source = Files.readString(repositoryRoot().resolve(file));
assertThat(source).contains(TYPE + ":")
.contains("provider-service: order-service")
.contains("provider-path: /internal/exports/v1/" + TYPE + "/batches")
.contains("required-permission: platform:order:export")
.contains("app-scope: PLATFORM");
}
}
@Test void fileOpenApiFreezesSelectionPrecedenceFiltersAndBusinessColumns() throws Exception {
String source = Files.readString(repositoryRoot().resolve(
"file/src/main/resources/openapi/domestic-order-export-v1.yaml"));
Map<String, Object> document = map(new Yaml().load(source));
Map<String, Object> schemas = map(map(document.get("components")).get("schemas"));
Map<String, Object> parameters = map(map(schemas.get("DomesticOrderExportParameters")).get("properties"));
Map<String, Object> columns = map(map(schemas.get("DomesticOrderExportColumns")).get("properties"));
assertThat(parameters).containsKeys("selectedOrderNos", "tenantId", "keyword", "orderNo", "mainStatus",
"customerId", "receivingWarehouseId", "destinationWarehouseId", "inboundCode",
"internalOrderStatus", "expectedArrivalDate", "feeReviewStatus", "channelName", "containerNo",
"cabinetNo", "sailingDate", "loaded", "fullContainer", "measurementReviewStatus", "fbaCode",
"createdFrom", "createdTo").doesNotContainKeys("page", "size", "orderId");
assertThat(columns.keySet()).containsExactly("inboundCode", "orderNo", "mainStatus", "customerName",
"channelName", "channelChanged", "receivingWarehouseName", "destinationWarehouseName",
"expectedShipDate", "expectedArrivalAt", "expectedVolumeCbm", "totalPackageCount", "totalQuantity",
"actualGrossWeightKg", "actualVolumeCbm", "chargeableWeightKg", "chargeableVolumeCbm",
"feeReviewStatus", "skuNos", "taxMode", "containerMode", "customsInspection", "containerNo",
"cabinetNo", "vesselName", "voyageNo", "createdAt");
assertThat(columns).containsKeys("expectedVolumeCbm", "expectedArrivalAt")
.doesNotContainKeys("orderId", "tenantId", "customerId", "inboundId", "receivingWarehouseId",
"destinationWarehouseId");
assertThat(source).contains("platform:order:export", "selectedOrderNos 非空时优先", "no-store");
}
private static Path repositoryRoot() {
Path current = Path.of("").toAbsolutePath();
return Files.isDirectory(current.resolve("file")) ? current : current.getParent();
}
@SuppressWarnings("unchecked")
private static Map<String, Object> map(Object value) { return (Map<String, Object>) value; }
}
@@ -18,10 +18,13 @@ import jakarta.annotation.Resource;
import com.baiyue.file.config.ExportDefinitionRegistry;
import java.util.Map;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Set;
import java.util.stream.IntStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verify;
@@ -112,6 +115,62 @@ class ExportTaskServiceTest {
.getResultCode());
}
@Test
void domesticOrderExportAcceptsSelectedBusinessNumbersAndTheCompleteFilterSnapshot() throws Exception {
var definition = definition();
definition.setProviderService("order-service");
definition.setProviderPath("/internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches");
definition.setRequiredPermission("platform:order:export");
when(registry.require(ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST)).thenReturn(definition);
when(states.createConstrained(any(FileExportTask.class))).thenAnswer(invocation ->
new ExportStateService.CreationResult(invocation.getArgument(0), true));
var user = user(1L, Set.of("platform:order:export"));
List<String> selected = IntStream.range(0, 5000).mapToObj(index -> "ORD-" + index).toList();
var selectedResponse = service.create(user, new CreateExportTaskRequest(
ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST, "XLSX", "国内仓订单", Map.of(
"selectedOrderNos", selected)));
assertEquals(ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST, selectedResponse.exportType());
var filters = new LinkedHashMap<String, Object>();
filters.put("tenantId", "10001"); filters.put("keyword", "订单"); filters.put("orderNo", "ORD-1");
filters.put("mainStatus", "WAIT_MEASURE"); filters.put("customerId", "10001");
filters.put("receivingWarehouseId", "11"); filters.put("destinationWarehouseId", "12");
filters.put("inboundCode", "IN-1"); filters.put("internalOrderStatus", "APPROVED");
filters.put("expectedArrivalDate", "2026-09-01"); filters.put("feeReviewStatus", "PENDING");
filters.put("channelName", "海运"); filters.put("containerNo", "CONT-1");
filters.put("cabinetNo", "CAB-1"); filters.put("sailingDate", "2026-09-02");
filters.put("loaded", false); filters.put("fullContainer", true);
filters.put("measurementReviewStatus", "APPROVED"); filters.put("fbaCode", "FBA-1");
filters.put("createdFrom", "2026-08-01T00:00:00"); filters.put("createdTo", "2026-09-01T23:59:59");
service.create(user, new CreateExportTaskRequest(
ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST, "XLSX", "国内仓订单", filters));
var tasks = org.mockito.ArgumentCaptor.forClass(FileExportTask.class);
verify(states, org.mockito.Mockito.times(2)).createConstrained(tasks.capture());
Map<?, ?> selectedSnapshot = new ObjectMapper().readValue(tasks.getAllValues().get(0).getParametersJson(), Map.class);
Map<?, ?> filterSnapshot = new ObjectMapper().readValue(tasks.getAllValues().get(1).getParametersJson(), Map.class);
assertEquals(5000, ((List<?>) selectedSnapshot.get("selectedOrderNos")).size());
assertEquals(filters, filterSnapshot);
}
@Test
void domesticOrderExportRequiresItsDedicatedPermission() {
var definition = definition();
definition.setProviderService("order-service");
definition.setProviderPath("/internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches");
definition.setRequiredPermission("platform:order:export");
when(registry.require(ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST)).thenReturn(definition);
BizException denied = assertThrows(BizException.class, () -> service.create(user(1L, Set.of("platform:order:list")),
new CreateExportTaskRequest(ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST,
"XLSX", "国内仓订单", Map.of())));
assertEquals(ResultCode.FORBIDDEN, denied.getResultCode());
assertTrue(denied.getMessage().contains("权限"));
}
private AuthenticatedUser user(long id, Set<String> permissions) {
return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", permissions);
}
@@ -136,6 +136,58 @@ class GenericXlsxExportRunnerTest {
}
}
@Test void domesticOrderProviderMustReturnTheFrozenBusinessColumnsWithoutInternalIds() throws Exception {
List<ExportColumn> invalid = ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_COLUMNS.stream()
.filter(column -> !"expectedVolumeCbm".equals(column.key()))
.map(column -> new ExportColumn(column.key(), column.title(), column.type(), 18)).toList();
var batch = new ExportBatchResponse(1, "sha256:domestic-orders", "snapshot-orders",
invalid, List.of(), null, false, 0L);
when(provider.batch(anyString(), eq(ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST), any(), any()))
.thenReturn(ApiResponse.success(batch));
FileExportTask task = domesticOrderTask(61L, Map.of());
var definition = new ExportDefinitionRegistry.Definition(); definition.setMaxRows(200000); definition.setBatchSize(500);
assertThrows(ExportFailure.class,
() -> runner.run(task, definition, new AtomicBoolean(), ignored -> { }));
}
@Test void domesticOrderColumnsGenerateWorkbookAndPassTheSelectionSnapshotToOrder() throws Exception {
List<ExportColumn> columns = ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_COLUMNS.stream()
.map(column -> new ExportColumn(column.key(), column.title(), column.type(), 18)).toList();
Map<String, Object> row = new java.util.LinkedHashMap<>();
row.put("inboundCode", "IN-1"); row.put("orderNo", "ORD-1");
row.put("expectedVolumeCbm", "1.25"); row.put("expectedArrivalAt", "2026-09-03T12:30:00");
var batch = new ExportBatchResponse(1, "sha256:domestic-orders", "snapshot-orders",
columns, List.of(row), null, false, 1L);
when(provider.batch(anyString(), eq(ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST), any(), any()))
.thenReturn(ApiResponse.success(batch));
Map<String, Object> parameters = Map.of("selectedOrderNos", List.of("ORD-1", "ORD-2"));
FileExportTask task = domesticOrderTask(62L, parameters);
var definition = new ExportDefinitionRegistry.Definition(); definition.setMaxRows(200000); definition.setBatchSize(500);
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(1).getStringCellValue());
assertEquals("预计到货时间", sheet.getRow(0).getCell(9).getStringCellValue());
assertEquals("预计体积(m³)", sheet.getRow(0).getCell(10).getStringCellValue());
assertEquals(1.25d, sheet.getRow(1).getCell(10).getNumericCellValue());
}
ArgumentCaptor<ExportBatchRequest> request = ArgumentCaptor.forClass(ExportBatchRequest.class);
verify(provider).batch(anyString(), eq(ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST),
eq(definition), request.capture());
assertEquals(parameters, request.getValue().parameters());
}
private FileExportTask domesticOrderTask(long id, Map<String, Object> parameters) throws Exception {
FileExportTask task = new FileExportTask(); task.setId(id); task.setTenantId(0L);
task.setAppCode("PLATFORM"); task.setRequestedBy(7L); task.setDispatchVersion(1);
task.setExportType(ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST); task.setTaskName("国内仓订单");
task.setParametersJson(new ObjectMapper().writeValueAsString(parameters));
return task;
}
@Configuration
static class TestConfig {
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
@@ -44,11 +44,34 @@ public final class InboundMeasurementResponses {
BigDecimal actualVolumeCbm,
BigDecimal volumeWeightKg,
String actualGoodsName,
@Schema(description = "Order 服务中的最新客户申报,只用于和既有实测事实对比;不得回写测量、箱码或审核结果")
DeclarationSnapshot latestDeclaration,
List<Item> items,
List<WeightResult> weightResults
) {
}
public record DeclarationSnapshot(
String referenceId,
Integer totalPackageCount,
BigDecimal grossWeightKg,
BigDecimal volumeCbm,
String goodsName,
List<DeclarationItem> items
) {
}
public record DeclarationItem(
Long inboundDetailId,
Integer itemNo,
String cargoName,
Integer packageCount,
BigDecimal lengthCm,
BigDecimal widthCm,
BigDecimal heightCm
) {
}
public record Item(
Long id,
Long inboundDetailId,
@@ -1,5 +1,5 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.util.List;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List;
public final class SmartLoadingDtos { private SmartLoadingDtos(){}
public record ChannelSummary(String channelCode, String channelName,
long candidateInboundCount, long availablePackageCount, long availableContainerCount) {}
@@ -9,12 +9,12 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg,
BigDecimal averageUnitVolumeCbm, BigDecimal availableVolumeCbm,
Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes,
Boolean locked, String blockReason) {}
Boolean locked, String blockReason, String orderStatusSnapshot, LocalDateTime expectedArrivalAt) {}
public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount,
BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm,
Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes,
Boolean locked, String blockReason) {}
Boolean locked, String blockReason, String orderStatusSnapshot, LocalDateTime expectedArrivalAt) {}
public record AssignRequest(@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId,
@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {}
public record AssignInboundRequest(@NotNull @Positive Long inboundId,
@@ -15,6 +15,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
@Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
o.order_status_snapshot orderStatusSnapshot,o.expected_arrival_at expectedArrivalAt,
o.warehouse_id warehouseId,o.channel_code channelCode,o.channel_name channelName,o.destination_warehouse_id destinationWarehouseId,
o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
@@ -64,6 +65,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
@Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
o.order_status_snapshot orderStatusSnapshot,o.expected_arrival_at expectedArrivalAt,
o.warehouse_id warehouseId,o.channel_code channelCode,o.channel_name channelName,o.destination_warehouse_id destinationWarehouseId,
o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
@@ -69,9 +69,8 @@ public class InboundMeasurementService {
? List.of() : repository.listItems(tenantId, measurement.getId());
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inboundId);
OrderCargoClient.MeasurementDeclarationSnapshot declaration = needsDeclarationFallback(measurement)
? loadDeclaration(inbound) : null;
return toDetail(inbound, measurement, items, weights, review, declaration);
InboundMeasurementResponses.DeclarationSnapshot latestDeclaration = loadLatestDeclaration(inbound);
return toDetail(inbound, measurement, items, weights, review, latestDeclaration);
}
public PageResult<InboundMeasurementResponses.PalletSnapshot> weightPalletOptions(
@@ -416,7 +415,8 @@ public class InboundMeasurementService {
private InboundMeasurementResponses.Detail toDetail(
InboundOrderEntity inbound, InboundMeasurementEntity measurement,
List<InboundMeasurementItemEntity> items, List<InboundWeightResultEntity> weights,
MeasureReviewRecordEntity review, OrderCargoClient.MeasurementDeclarationSnapshot declaration) {
MeasureReviewRecordEntity review,
InboundMeasurementResponses.DeclarationSnapshot latestDeclaration) {
return new InboundMeasurementResponses.Detail(
inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(),
inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(),
@@ -428,26 +428,67 @@ public class InboundMeasurementService {
inbound.getVersion(),
measurement == null ? null : measurement.getVersion(),
measurement != null && StringUtils.hasText(measurement.getOriginalDocumentNo())
? measurement.getOriginalDocumentNo() : declaration == null ? null : declaration.referenceId(),
? measurement.getOriginalDocumentNo() : latestDeclaration.referenceId(),
measurement != null && measurement.getOriginalGrossWeightKg() != null
? measurement.getOriginalGrossWeightKg() : declaration == null ? null : declaration.originalGrossWeightKg(),
? measurement.getOriginalGrossWeightKg() : latestDeclaration.grossWeightKg(),
measurement != null && measurement.getOriginalVolumeCbm() != null
? measurement.getOriginalVolumeCbm() : declaration == null ? null : declaration.originalVolumeCbm(),
inbound.getTotalPackageCount(),
? measurement.getOriginalVolumeCbm() : latestDeclaration.volumeCbm(),
latestDeclaration.totalPackageCount(),
measurement == null ? inbound.getTotalPackageCount() : measurement.getTotalPackageCount(),
inbound.getActualGrossWeightKg(),
inbound.getActualNetWeightKg(),
measurement == null ? inbound.getActualVolumeCbm() : measurement.getTotalVolumeCbm(),
measurement == null ? inbound.getVolumeWeightKg() : measurement.getVolumeWeightKg(),
measurement != null && StringUtils.hasText(measurement.getActualGoodsName())
? measurement.getActualGoodsName() : declaration == null ? null : declaration.defaultActualGoodsName(),
? measurement.getActualGoodsName() : latestDeclaration.goodsName(),
latestDeclaration,
items.stream().map(this::toItem).toList(), weights.stream().map(this::toWeight).toList());
}
private boolean needsDeclarationFallback(InboundMeasurementEntity measurement) {
return measurement == null || !StringUtils.hasText(measurement.getOriginalDocumentNo())
|| measurement.getOriginalGrossWeightKg() == null || measurement.getOriginalVolumeCbm() == null
|| !StringUtils.hasText(measurement.getActualGoodsName());
/**
* Latest customer declaration is a live, read-only Order view. It must never be persisted over
* the measurement batch, carton-label mapping, weight results or review decision.
*/
private InboundMeasurementResponses.DeclarationSnapshot loadLatestDeclaration(
InboundOrderEntity inbound) {
OrderCargoClient.MeasurementDeclarationSnapshot aggregate = loadDeclaration(inbound);
List<OrderCargoClient.CargoLabelBinding> cargoes = loadDeclarationCargoes(inbound);
long packages = cargoes.stream()
.map(OrderCargoClient.CargoLabelBinding::packageCount)
.filter(java.util.Objects::nonNull)
.mapToLong(Integer::longValue)
.sum();
if (packages > Integer.MAX_VALUE) {
throw new BizException(ResultCode.VALIDATION_FAILED, "客户最新申报总箱数超过系统上限");
}
return new InboundMeasurementResponses.DeclarationSnapshot(
aggregate.referenceId(), (int) packages, aggregate.originalGrossWeightKg(),
aggregate.originalVolumeCbm(), aggregate.defaultActualGoodsName(),
cargoes.stream()
.sorted(java.util.Comparator.comparing(OrderCargoClient.CargoLabelBinding::itemNo,
java.util.Comparator.nullsLast(Integer::compareTo))
.thenComparing(OrderCargoClient.CargoLabelBinding::cargoId,
java.util.Comparator.nullsLast(Long::compareTo)))
.map(cargo -> new InboundMeasurementResponses.DeclarationItem(
cargo.cargoId(), cargo.itemNo(), cargo.cargoName(), cargo.packageCount(),
cargo.lengthCm(), cargo.widthCm(), cargo.heightCm()))
.toList());
}
private List<OrderCargoClient.CargoLabelBinding> loadDeclarationCargoes(InboundOrderEntity inbound) {
try {
ApiResponse<List<OrderCargoClient.CargoLabelBinding>> response = orderCargoClient.listForCartonLabels(
internalToken, inbound.getTenantId(), inbound.getOrderId());
if (response == null || !response.successful() || response.data() == null) {
String message = response == null || !StringUtils.hasText(response.message())
? "客户订单申报明细查询失败" : response.message();
throw new BizException(response == null ? ResultCode.SERVICE_UNAVAILABLE
: ResultCode.of(response.code()), message);
}
return response.data();
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法读取客户最新申报明细");
}
}
private OrderCargoClient.MeasurementDeclarationSnapshot loadDeclaration(InboundOrderEntity inbound) {
@@ -113,7 +113,8 @@ public class SmartLoadingService {
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
first.customerName(), goods, packages, first.unitGrossWeightKg(), first.unitNetWeightKg(),
averageVolume, volume, first.coLoadGroupId(), first.groupNo(), first.memberCount(),
first.groupTotalBoxes(), first.locked(), first.blockReason());
first.groupTotalBoxes(), first.locked(), first.blockReason(), first.orderStatusSnapshot(),
first.expectedArrivalAt());
}
public Plan draftByBooking(long bookingId) {
@@ -563,7 +564,8 @@ public class SmartLoadingService {
candidate.customerName(), candidate.goodsName(), candidate.availablePackageCount(),
candidate.unitGrossWeightKg(), candidate.unitNetWeightKg(), candidate.unitVolumeCbm(),
group.groupId(), group.groupNo(), group.members().size(), group.fullPackageCount(),
group.lockedPlanId() != null, blocks.get(group.groupId()));
group.lockedPlanId() != null, blocks.get(group.groupId()), candidate.orderStatusSnapshot(),
candidate.expectedArrivalAt());
}).toList();
}
@@ -409,6 +409,7 @@ paths:
description: >-
需要 platform:firstleg:smart-loading:list 权限。每个入库单/入仓码只返回一行;
平均单箱毛重、净重按整票实测重量除以总箱数,平均单箱体积按剩余规格数量加权。
响应携带订单状态快照 orderStatusSnapshot 与预计到货时间 expectedArrivalAt,供平台筛选候选入仓码;
这些数据用于数量级计划,不表示具体箱号已核验归属。
parameters:
- {name: bookingId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -1415,10 +1416,10 @@ components:
measurementStatus: {type: string, nullable: true}
inboundVersion: {type: integer, minimum: 0}
version: {type: integer, minimum: 0, nullable: true}
originalDocumentNo: {type: string, nullable: true, description: 客户订单 referenceId 的服务端快照;旧空快照按订单事实回退}
originalGrossWeightKg: {type: number, format: double, nullable: true, description: 客户订单有效货物行 grossWeightKg 合计的服务端快照}
originalVolumeCbm: {type: number, format: double, nullable: true, description: 客户订单有效货物行 volumeCbm 合计的服务端快照}
originalPackageCount: {type: integer, nullable: true}
originalDocumentNo: {type: string, nullable: true, description: 测量批次开始时冻结的客户订单 referenceId;批次为空时才以最新申报回退}
originalGrossWeightKg: {type: number, format: double, nullable: true, description: 测量批次开始时冻结的客户申报毛重;不得被后续订单编辑覆盖}
originalVolumeCbm: {type: number, format: double, nullable: true, description: 测量批次开始时冻结的客户申报体积;不得被后续订单编辑覆盖}
originalPackageCount: {type: integer, nullable: true, description: 最新客户申报总箱数;仅用于与 actualPackageCount 对比}
actualPackageCount: {type: integer, nullable: true}
actualGrossWeightKg:
type: number
@@ -1435,6 +1436,8 @@ components:
actualVolumeCbm: {type: number, format: double, nullable: true}
volumeWeightKg: {type: number, format: double, nullable: true}
actualGoodsName: {type: string, nullable: true, description: 默认由客户货物品名去重汇总,测量阶段允许人工修正}
latestDeclaration:
$ref: '#/components/schemas/InboundMeasurementLatestDeclaration'
items:
type: array
description: DIMENSION 尺寸测量明细及其图片证据
@@ -1443,6 +1446,31 @@ components:
type: array
description: WEIGHT 称重结果及其图片、确认状态和服务端重量事实
items: {$ref: '#/components/schemas/InboundWeightResult'}
InboundMeasurementLatestDeclaration:
type: object
description: Order 服务中的最新客户申报只读视图;订单编辑只能改变本对象,不得覆盖箱码、实测尺寸/重量或审核结果
required: [totalPackageCount, items]
properties:
referenceId: {type: string, nullable: true}
totalPackageCount: {type: integer, minimum: 0}
grossWeightKg: {type: number, format: double, nullable: true}
volumeCbm: {type: number, format: double, nullable: true}
goodsName: {type: string, nullable: true}
items:
type: array
items: {$ref: '#/components/schemas/InboundMeasurementDeclarationItem'}
InboundMeasurementDeclarationItem:
type: object
description: 最新订单货物申报行;inboundDetailId 仅用于与实测明细关联,不作为用户展示编号
required: [inboundDetailId, itemNo, packageCount]
properties:
inboundDetailId: {type: string, pattern: '^[1-9][0-9]*$'}
itemNo: {type: integer, minimum: 1}
cargoName: {type: string, nullable: true}
packageCount: {type: integer, minimum: 0}
lengthCm: {type: number, format: double, nullable: true}
widthCm: {type: number, format: double, nullable: true}
heightCm: {type: number, format: double, nullable: true}
InboundMeasurementSaveDraftRequest:
type: object
required: [version, items]
@@ -24,7 +24,14 @@ class InboundMeasurementEvidenceOpenApiContractTest {
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
Map<String, Object> detailProperties = properties(schemas, "InboundMeasurementDetail");
assertThat(detailProperties).containsKeys(
"actualGrossWeightKg", "actualNetWeightKg", "items", "weightResults");
"actualGrossWeightKg", "actualNetWeightKg", "latestDeclaration", "items", "weightResults");
assertThat(stringMap(detailProperties.get("latestDeclaration")))
.containsEntry("$ref", "#/components/schemas/InboundMeasurementLatestDeclaration");
assertThat(properties(schemas, "InboundMeasurementLatestDeclaration"))
.containsKeys("referenceId", "totalPackageCount", "grossWeightKg", "volumeCbm", "goodsName", "items");
assertThat(properties(schemas, "InboundMeasurementDeclarationItem"))
.containsKeys("inboundDetailId", "itemNo", "cargoName", "packageCount",
"lengthCm", "widthCm", "heightCm");
assertThat(stringMap(detailProperties.get("items")))
.containsEntry("description", "DIMENSION 尺寸测量明细及其图片证据");
assertThat(stringMap(detailProperties.get("weightResults")))
@@ -13,7 +13,7 @@ class SmartLoadingCandidateMapperContractTest {
@Test
void candidateHasOnlyTheCanonicalMybatisConstructor() {
assertThat(Candidate.class.getDeclaredConstructors()).singleElement()
.satisfies(constructor -> assertThat(constructor.getParameterCount()).isEqualTo(24));
.satisfies(constructor -> assertThat(constructor.getParameterCount()).isEqualTo(26));
}
@Test
@@ -23,6 +23,8 @@ class SmartLoadingCandidateMapperContractTest {
assertThat(sql)
.contains("o.destination_warehouse_id destinationWarehouseId")
.contains("o.order_status_snapshot orderStatusSnapshot")
.contains("o.expected_arrival_at expectedArrivalAt")
.contains("m.status='APPROVED'")
.contains("SELECT MAX(m2.id)")
.contains("m2.status='APPROVED'")
@@ -51,6 +53,8 @@ class SmartLoadingCandidateMapperContractTest {
assertThat(sql)
.contains("o.destination_warehouse_id destinationWarehouseId")
.contains("o.order_status_snapshot orderStatusSnapshot")
.contains("o.expected_arrival_at expectedArrivalAt")
.contains("m.status='APPROVED'")
.contains("SELECT MAX(m2.id)")
.contains("o.warehouse_id=#{warehouseId}")
@@ -65,6 +65,10 @@ class InboundMeasurementServiceTest {
when(orderCargoClient.measurementDeclaration("internal-token", 1L, 80L)).thenReturn(
ApiResponse.success(new OrderCargoClient.MeasurementDeclarationSnapshot(
"REF-50", new BigDecimal("2000"), new BigDecimal("20"), "123123")));
when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn(
ApiResponse.success(List.of(new OrderCargoClient.CargoLabelBinding(
80001L, 80L, 1, "123123", 3, new BigDecimal("40"),
new BigDecimal("30"), new BigDecimal("20")))));
}
@Test
@@ -213,6 +217,71 @@ class InboundMeasurementServiceTest {
});
}
@Test
void detailComparesLatestDeclarationWithoutMutatingFrozenMeasurementFacts() {
InboundOrderEntity inbound = inbound("WAIT_SPLIT");
inbound.setMeasureReviewStatus("APPROVED");
inbound.setActualGrossWeightKg(new BigDecimal("30.000"));
inbound.setActualNetWeightKg(new BigDecimal("11.500"));
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L);
measurement.setStatus("APPROVED");
measurement.setOriginalDocumentNo("OLD-REF");
measurement.setOriginalGrossWeightKg(new BigDecimal("25.000"));
measurement.setOriginalVolumeCbm(new BigDecimal("0.5000"));
measurement.setTotalPackageCount(2);
measurement.setTotalVolumeCbm(new BigDecimal("0.6000"));
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setId(40L);
item.setTenantId(1L);
item.setMeasurementId(30L);
item.setInboundDetailId(80001L);
item.setBoxCode("RK-001#2-1");
item.setPackageCount(2);
item.setLengthCm(new BigDecimal("45"));
item.setWidthCm(new BigDecimal("35"));
item.setHeightCm(new BigDecimal("25"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement);
when(repository.listItems(1L, 30L)).thenReturn(List.of(item));
when(repository.listWeights(1L, 10L)).thenReturn(List.of());
when(orderCargoClient.measurementDeclaration("internal-token", 1L, 80L)).thenReturn(
ApiResponse.success(new OrderCargoClient.MeasurementDeclarationSnapshot(
"NEW-REF", new BigDecimal("40.000"), new BigDecimal("0.8000"), "最新申报品名")));
when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn(
ApiResponse.success(List.of(new OrderCargoClient.CargoLabelBinding(
80001L, 80L, 1, "最新申报品名", 4, new BigDecimal("50"),
new BigDecimal("40"), new BigDecimal("30")))));
InboundMeasurementResponses.Detail result = service.detail(1L, 10L);
assertThat(result.originalDocumentNo()).isEqualTo("OLD-REF");
assertThat(result.originalGrossWeightKg()).isEqualByComparingTo("25.000");
assertThat(result.originalVolumeCbm()).isEqualByComparingTo("0.5000");
assertThat(result.actualPackageCount()).isEqualTo(2);
assertThat(result.latestDeclaration()).satisfies(latest -> {
assertThat(latest.referenceId()).isEqualTo("NEW-REF");
assertThat(latest.totalPackageCount()).isEqualTo(4);
assertThat(latest.grossWeightKg()).isEqualByComparingTo("40.000");
assertThat(latest.volumeCbm()).isEqualByComparingTo("0.8000");
assertThat(latest.items()).singleElement().satisfies(declared -> {
assertThat(declared.inboundDetailId()).isEqualTo(80001L);
assertThat(declared.packageCount()).isEqualTo(4);
assertThat(declared.lengthCm()).isEqualByComparingTo("50");
});
});
assertThat(result.items()).singleElement().satisfies(measured -> {
assertThat(measured.id()).isEqualTo(40L);
assertThat(measured.boxCode()).isEqualTo("RK-001#2-1");
assertThat(measured.packageCount()).isEqualTo(2);
assertThat(measured.lengthCm()).isEqualByComparingTo("45");
});
verify(repository, never()).updateInbound(any());
verify(repository, never()).updateMeasurement(any());
verify(repository, never()).updateItem(any());
verify(repository, never()).deleteWeight(anyLong(), anyLong(), anyLong(), anyInt());
}
@Test
void creatingWeightResultNormalizesProviderConfidenceAtBusinessBoundary() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
@@ -122,11 +122,11 @@ class SmartLoadingServiceTest {
Candidate first = new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L,
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "服装", 3,
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.2"),
null, null, null, null, null, null);
null, null, null, null, null, null, "WAIT_SPLIT", LocalDateTime.parse("2026-09-10T09:30:00"));
Candidate second = new Candidate(9L, 10L, 21L, 30L, "O", "I", 100L,
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "鞋类", 7,
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5"),
null, null, null, null, null, null);
null, null, null, null, null, null, "WAIT_SPLIT", LocalDateTime.parse("2026-09-10T09:30:00"));
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidates(100L)).thenReturn(List.of(first, second));
@@ -139,6 +139,8 @@ class SmartLoadingServiceTest {
assertThat(candidate.averageUnitVolumeCbm()).isEqualByComparingTo("0.410000");
assertThat(candidate.availableVolumeCbm()).isEqualByComparingTo("4.1000");
assertThat(candidate.unitGrossWeightKg()).isEqualByComparingTo("2");
assertThat(candidate.orderStatusSnapshot()).isEqualTo("WAIT_SPLIT");
assertThat(candidate.expectedArrivalAt()).isEqualTo(LocalDateTime.parse("2026-09-10T09:30:00"));
});
}
@@ -440,15 +442,15 @@ class SmartLoadingServiceTest {
Candidate first = new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L,
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "A", 30,
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.2"),
null, null, null, null, null, null);
null, null, null, null, null, null, null, null);
Candidate second = new Candidate(9L, 10L, 21L, 30L, "O", "I", 100L,
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "B", 40,
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5"),
null, null, null, null, null, null);
null, null, null, null, null, null, null, null);
Candidate third = new Candidate(9L, 10L, 22L, 30L, "O", "I", 100L,
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "C", 30,
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.8"),
null, null, null, null, null, null);
null, null, null, null, null, null, null, null);
var result = service.proportionalAllocations(List.of(first, second, third), 60);
@@ -1109,7 +1111,7 @@ class SmartLoadingServiceTest {
return new Candidate(tenantId, inboundId, detailId, orderId, "O" + orderId, "I" + inboundId,
100L, "FBA", "FBA渠道", 200L, destinationCode, "FBA仓", "客户", "货物", boxes,
new BigDecimal("2"), new BigDecimal("1.5"),
new BigDecimal("0.5"), null, null, null, null, null, null);
new BigDecimal("0.5"), null, null, null, null, null, null, null, null);
}
private static Candidate withChannel(Candidate value, String channelCode, String channelName) {
@@ -1118,7 +1120,8 @@ class SmartLoadingServiceTest {
value.destinationWarehouseId(), value.destinationWarehouseCode(), value.destinationWarehouseName(),
value.customerName(), value.goodsName(), value.availablePackageCount(), value.unitGrossWeightKg(),
value.unitNetWeightKg(), value.unitVolumeCbm(), value.coLoadGroupId(), value.groupNo(),
value.memberCount(), value.groupTotalBoxes(), value.locked(), value.blockReason());
value.memberCount(), value.groupTotalBoxes(), value.locked(), value.blockReason(),
value.orderStatusSnapshot(), value.expectedArrivalAt());
}
private static ChannelRow channel(String channelCode, String channelName) {
@@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
import java.time.LocalDateTime;
import java.util.List;
import com.baiyue.order.dto.OrderDeclarationDtos;
/** 订单审核事件投递到头程入库域的内部客户端。 */
@FeignClient(name = "firstleg-service", contextId = "orderFirstlegInboundClient",
@@ -31,6 +32,10 @@ public interface FirstlegInboundClient {
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody InboundReferenceRequest request);
@PostMapping("/order-declaration-updated")
ApiResponse<Void> updateDeclaration(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody OrderDeclarationDtos.UpdatedEvent event);
record InboundReferenceRequest(long tenantId, List<Long> orderIds) {
}
@@ -7,7 +7,9 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.order.dto.UnifiedOrderQuery;
import com.baiyue.order.dto.UnifiedOrderResponse;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.dto.OrderDeclarationDtos;
import com.baiyue.order.service.OrderApplicationService;
import com.baiyue.order.service.OrderDeclarationUpdateService;
import com.baiyue.order.service.OrderScopeGuard;
import com.baiyue.order.service.UnifiedOrderQueryService;
import com.baiyue.order.service.OrderFulfillmentSplitService;
@@ -25,6 +27,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PutMapping;
import java.util.List;
/** Platform product-level order query. Legacy first-leg paths remain available. */
@@ -35,6 +38,7 @@ public class PlatformUnifiedOrderController {
@Resource private UnifiedOrderQueryService service;
@Resource private OrderApplicationService orderService;
@Resource private OrderFulfillmentSplitService splitService;
@Resource private OrderDeclarationUpdateService declarationUpdates;
@GetMapping
@PreAuthorize("hasAuthority('platform:order:list')")
@@ -58,6 +62,22 @@ public class PlatformUnifiedOrderController {
return ApiResponse.success(service.platformDetail(OrderScopeGuard.platform(user, tenantId), orderId));
}
@PutMapping("/{orderId}/declaration")
@PreAuthorize("hasAuthority('platform:order:declaration:edit')")
@BusinessAudit(event = "ORDER_DECLARATION_UPDATE", resourceType = "FIRST_LEG_ORDER",
resourceId = "#orderId", tenantId = "#tenantId", description = "平台修改客户原始申报资料")
@Operation(summary = "版本化修改客户原始申报资料",
description = "只修改客户申报快照;不修改客户、收货仓、入仓码、实测、PDA、审核或计费事实。")
public ApiResponse<OrderDeclarationDtos.UpdateResponse> updateDeclaration(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long orderId,
@Valid @RequestBody OrderDeclarationDtos.UpdateRequest request) {
long scopedTenant = OrderScopeGuard.platform(user, tenantId);
return ApiResponse.success(declarationUpdates.update(scopedTenant, orderId, user.userId(),
user.username(), request));
}
@GetMapping("/{orderId}/fulfillment-tree")
@PreAuthorize("hasAuthority('platform:order:list')")
@Operation(summary = "父子订单与柜次数量摘要")
@@ -0,0 +1,69 @@
package com.baiyue.order.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** 平台修改客户原始申报资料的独立版本化契约。 */
public final class OrderDeclarationDtos {
private OrderDeclarationDtos() {}
public record UpdateRequest(
@NotNull @Min(0) Integer expectedVersion,
@Size(max = 1000) String customerRemark,
LocalDateTime expectedArrivalAt,
@Size(max = 64) String channelCode,
@Size(max = 128) String channelName,
@NotEmpty @Size(max = 500) List<@Valid Cargo> cargoes
) {}
/** itemNo 是订单内稳定业务行号;不以货物数据库 ID 作为编辑契约。 */
public record Cargo(
@NotNull @Min(1) @Max(500) Integer itemNo,
@NotBlank @Size(max = 128) String cargoName,
@Size(max = 128) String cargoNameEn,
@Size(max = 64) String skuNo,
@Size(max = 64) String model,
@NotNull @Min(1) Integer packageCount,
@NotNull @Min(1) Integer totalQuantity,
@NotNull @DecimalMin("0.01") BigDecimal lengthCm,
@NotNull @DecimalMin("0.01") BigDecimal widthCm,
@NotNull @DecimalMin("0.01") BigDecimal heightCm,
@NotNull @DecimalMin("0.001") BigDecimal grossWeightKg,
@NotNull @DecimalMin("0.0001") BigDecimal volumeCbm,
@DecimalMin("0.001") BigDecimal netWeightKg,
@Size(max = 20) String hsCode,
@Size(max = 20) String customsHsCode,
@Min(1) Integer unitsPerPackage,
@DecimalMin("0.00") BigDecimal unitDeclaredValue,
@DecimalMin("0.00") BigDecimal declaredValue,
@Pattern(regexp = "[A-Z]{3}") String currency,
@Size(max = 128) String material,
@Size(max = 128) String usage,
@Size(max = 64) String brand,
@Size(max = 500) String cargoRemark
) {}
public record UpdateResponse(String orderNo, String inboundCode, int previousVersion, int version,
boolean channelChangedBefore, boolean channelChanged,
List<FieldChange> changes) {}
public record FieldChange(Integer itemNo, String field, Object beforeValue, Object afterValue) {}
/** Order -> Firstleg 版本化 Outbox 快照;消费者须按 eventId 幂等并按 version 顺序应用。 */
public record UpdatedEvent(int schemaVersion, String eventId, long tenantId, long orderId,
String orderNo, String inboundCode, int version, long operatorId,
LocalDateTime occurredAt, String customerRemark,
LocalDateTime expectedArrivalAt, String channelCode, String channelName,
boolean channelChanged, List<Cargo> cargoes, List<FieldChange> changes) {}
}
@@ -15,12 +15,12 @@ public final class UnifiedOrderResponse {
Long orderId, Long tenantId, String orderNo, String mainStatus, String nextAction,
OrderRelationSummary relation,
OrderStatus internalOrderStatus,
Long customerId, String customerName, String channelCode, String channelName,
Long customerId, String customerName, String channelCode, String channelName, Boolean channelChanged,
Long receivingWarehouseId, String receivingWarehouseName,
Long destinationWarehouseId, String destinationWarehouseName,
Long inboundId, Integer inboundVersion, String inboundCode, LocalDate expectedShipDate,
Integer totalPackageCount, String recommendedPalletType, String locationCodes,
BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm,
BigDecimal expectedVolumeCbm, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm,
LocalDateTime expectedArrivalAt, String feeReviewStatus,
String companyName, String businessType, String pickupMethod,
String taxMode, String containerMode, Boolean customsInspection,
@@ -31,6 +31,7 @@ public class OrderEntity extends BaseEntity {
private Long destinationWarehouseId;
private String destinationWarehouseName;
private LocalDate expectedShipDate;
private LocalDateTime expectedArrivalAt;
private String customerRemark;
private String pickupMethod;
private LocalDateTime requestedPickupAt;
@@ -40,6 +41,7 @@ public class OrderEntity extends BaseEntity {
private String receivingTime;
private String channelCode;
private String channelName;
private Boolean channelChanged;
private Long carrierId;
private String carrierName;
private String taxMode;
@@ -54,6 +54,56 @@ public class OrderRepository {
.eq(OrderEntity::getId, order.getId())
.eq(OrderEntity::getTenantId, tenantId));
}
public int updateDeclarationHeader(long tenantId, long orderId, int expectedVersion, long operatorId,
String customerRemark, LocalDateTime expectedArrivalAt,
String channelCode, String channelName, boolean channelChanged) {
return orderMapper.update(null, new LambdaUpdateWrapper<OrderEntity>()
.eq(OrderEntity::getTenantId, tenantId)
.eq(OrderEntity::getId, orderId)
.eq(OrderEntity::getVersion, expectedVersion)
.eq(OrderEntity::getDeleted, false)
.set(OrderEntity::getCustomerRemark, customerRemark)
.set(OrderEntity::getExpectedArrivalAt, expectedArrivalAt)
.set(OrderEntity::getChannelCode, channelCode)
.set(OrderEntity::getChannelName, channelName)
.set(OrderEntity::getChannelChanged, channelChanged)
.set(OrderEntity::getUpdatedBy, operatorId)
.set(OrderEntity::getUpdatedAt, LocalDateTime.now())
.setSql("version = version + 1"));
}
public int updateDeclarationCargo(long tenantId, long orderId, OrderCargoEntity cargo) {
return cargoMapper.update(null, new LambdaUpdateWrapper<OrderCargoEntity>()
.eq(OrderCargoEntity::getTenantId, tenantId)
.eq(OrderCargoEntity::getOrderId, orderId)
.eq(OrderCargoEntity::getId, cargo.getId())
.eq(OrderCargoEntity::getVersion, cargo.getVersion())
.eq(OrderCargoEntity::getDeleted, false)
.set(OrderCargoEntity::getCargoName, cargo.getCargoName())
.set(OrderCargoEntity::getCargoNameEn, cargo.getCargoNameEn())
.set(OrderCargoEntity::getSkuNo, cargo.getSkuNo())
.set(OrderCargoEntity::getModel, cargo.getModel())
.set(OrderCargoEntity::getPackageCount, cargo.getPackageCount())
.set(OrderCargoEntity::getTotalQuantity, cargo.getTotalQuantity())
.set(OrderCargoEntity::getLengthCm, cargo.getLengthCm())
.set(OrderCargoEntity::getWidthCm, cargo.getWidthCm())
.set(OrderCargoEntity::getHeightCm, cargo.getHeightCm())
.set(OrderCargoEntity::getGrossWeightKg, cargo.getGrossWeightKg())
.set(OrderCargoEntity::getVolumeCbm, cargo.getVolumeCbm())
.set(OrderCargoEntity::getNetWeightKg, cargo.getNetWeightKg())
.set(OrderCargoEntity::getHsCode, cargo.getHsCode())
.set(OrderCargoEntity::getCustomsHsCode, cargo.getCustomsHsCode())
.set(OrderCargoEntity::getUnitsPerPackage, cargo.getUnitsPerPackage())
.set(OrderCargoEntity::getUnitDeclaredValue, cargo.getUnitDeclaredValue())
.set(OrderCargoEntity::getDeclaredValue, cargo.getDeclaredValue())
.set(OrderCargoEntity::getCurrency, cargo.getCurrency())
.set(OrderCargoEntity::getMaterial, cargo.getMaterial())
.set(OrderCargoEntity::getProductUsage, cargo.getProductUsage())
.set(OrderCargoEntity::getBrand, cargo.getBrand())
.set(OrderCargoEntity::getCargoRemark, cargo.getCargoRemark())
.set(OrderCargoEntity::getUpdatedBy, cargo.getUpdatedBy())
.set(OrderCargoEntity::getUpdatedAt, cargo.getUpdatedAt())
.setSql("version = version + 1"));
}
public void insertCargo(OrderCargoEntity cargo) { cargoMapper.insert(cargo); }
public void insertParty(OrderPartyEntity party) { partyMapper.insert(party); }
public void insertNote(OrderInternalNoteEntity note) { noteMapper.insert(note); }
@@ -49,9 +49,12 @@ public class DomesticWarehouseOrderExportProviderService {
column("mainStatus", "订单主状态", ExportColumnType.STRING, 16),
column("customerName", "客户", ExportColumnType.STRING, 24),
column("channelName", "渠道", ExportColumnType.STRING, 20),
column("channelChanged", "曾变更渠道", ExportColumnType.BOOLEAN, 12),
column("receivingWarehouseName", "定位仓库", ExportColumnType.STRING, 20),
column("destinationWarehouseName", "交货仓", ExportColumnType.STRING, 20),
column("expectedShipDate", "预计出运日期", ExportColumnType.DATE, 14),
column("expectedArrivalAt", "预计到货时间", ExportColumnType.DATETIME, 21),
column("expectedVolumeCbm", "预计体积(m³)", ExportColumnType.DECIMAL, 16),
column("totalPackageCount", "箱数", ExportColumnType.INTEGER, 12),
column("totalQuantity", "产品数量", ExportColumnType.INTEGER, 12),
column("actualGrossWeightKg", "实测毛重(kg)", ExportColumnType.DECIMAL, 16),
@@ -124,8 +127,9 @@ public class DomesticWarehouseOrderExportProviderService {
Map<String, Object> row = new LinkedHashMap<>();
row.put("inboundCode", value.inboundCode()); row.put("orderNo", value.orderNo());
row.put("mainStatus", value.mainStatus()); row.put("customerName", value.customerName());
row.put("channelName", value.channelName()); row.put("receivingWarehouseName", value.receivingWarehouseName());
row.put("channelName", value.channelName()); row.put("channelChanged", value.channelChanged()); row.put("receivingWarehouseName", value.receivingWarehouseName());
row.put("destinationWarehouseName", value.destinationWarehouseName()); row.put("expectedShipDate", value.expectedShipDate());
row.put("expectedArrivalAt", value.expectedArrivalAt()); row.put("expectedVolumeCbm", value.expectedVolumeCbm());
row.put("totalPackageCount", value.totalPackageCount()); row.put("totalQuantity", value.totalQuantity());
row.put("actualGrossWeightKg", value.actualGrossWeightKg()); row.put("actualVolumeCbm", value.actualVolumeCbm());
row.put("chargeableWeightKg", value.chargeableWeightKg()); row.put("chargeableVolumeCbm", value.chargeableVolumeCbm());
@@ -466,7 +466,9 @@ public class OrderApplicationService {
? addresses.privateDestination().getAddressName()
: snapshots.destination() == null ? request.destinationWarehouseName()
: snapshots.destination().warehouse().warehouseName());
order.setExpectedShipDate(request.expectedShipDate()); order.setCustomerRemark(request.customerRemark());
order.setExpectedShipDate(request.expectedShipDate());
order.setExpectedArrivalAt(request.expectedShipDate() == null ? null : request.expectedShipDate().atStartOfDay());
order.setCustomerRemark(request.customerRemark());
order.setPickupMethod(request.pickupMethod());
order.setRequestedPickupAt("PICKUP".equals(request.pickupMethod()) ? request.requestedPickupAt() : null);
order.setReceivingWarehouseId(request.receivingWarehouseId());
@@ -477,6 +479,7 @@ public class OrderApplicationService {
// keep one canonical code so later estimation and audit lookups are stable.
order.setChannelCode(request.channelCode().trim().toUpperCase(Locale.ROOT));
order.setChannelName(request.channelName());
if (order.getChannelChanged() == null) order.setChannelChanged(false);
order.setCarrierId(request.carrierId());
order.setCarrierName(request.carrierId() == null || !StringUtils.hasText(request.carrierName())
? null : request.carrierName().trim());
@@ -671,7 +674,8 @@ public class OrderApplicationService {
order.getTenantId(), order.getId(), order.getOrderNo(), order.getInboundCode(),
order.getCustomerId(), order.getCustomerName(),
order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getDestinationWarehouseId(),
order.getDestinationWarehouseName(), order.getExpectedShipDate() == null ? null : order.getExpectedShipDate().atStartOfDay(),
order.getDestinationWarehouseName(), order.getExpectedArrivalAt() != null ? order.getExpectedArrivalAt()
: order.getExpectedShipDate() == null ? null : order.getExpectedShipDate().atStartOfDay(),
order.getDestinationType() + "_" + order.getTransportMode(), order.getChannelCode(), order.getChannelName(),
order.getCarrierId(), order.getCarrierName(),
order.getTaxMode(), order.getPricingTaxMode(),
@@ -0,0 +1,179 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.OrderDeclarationDtos;
import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.entity.OrderStatusHistoryEntity;
import com.baiyue.order.entity.OutboxEventEntity;
import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/** 平台版本化修改客户申报快照;绝不写入仓库实测、PDA、测量审核或计费事实。 */
@Service
public class OrderDeclarationUpdateService {
private static final Set<OrderStatus> EDITABLE = Set.of(
OrderStatus.DRAFT, OrderStatus.REJECTED, OrderStatus.SUBMITTED, OrderStatus.APPROVED);
@Resource private OrderRepository repository;
@Resource private OrderPricingChannelGuard pricingChannelGuard;
@Resource private UnifiedOrderQueryService unifiedOrders;
@Resource private ObjectMapper json;
@Transactional
public OrderDeclarationDtos.UpdateResponse update(long tenantId, long orderId, long operatorId,
String operatorName,
OrderDeclarationDtos.UpdateRequest request) {
OrderEntity order = repository.findOwned(tenantId, orderId);
if (order == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在");
if (!EDITABLE.contains(order.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "订单已进入不可逆履约状态,不能修改客户申报资料");
}
if (!Objects.equals(order.getVersion(), request.expectedVersion())) {
throw new BizException(ResultCode.CONFLICT, "订单版本已变化,请刷新详情后重试");
}
List<OrderCargoEntity> current = repository.findCargo(tenantId, orderId);
Map<Integer, OrderCargoEntity> byItemNo = new HashMap<>();
current.forEach(item -> byItemNo.put(item.getItemNo(), item));
Set<Integer> requestedItems = new HashSet<>();
request.cargoes().forEach(item -> requestedItems.add(item.itemNo()));
if (requestedItems.size() != request.cargoes().size() || !requestedItems.equals(byItemNo.keySet())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "货物行必须与当前订单完整一致,不允许新增、删除或重复行");
}
String channelCode = value(request.channelCode(), order.getChannelCode());
String channelName = value(request.channelName(), order.getChannelName());
if (!StringUtils.hasText(channelCode) || !StringUtils.hasText(channelName)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "渠道编码和名称必须同时填写");
}
channelCode = channelCode.toUpperCase(Locale.ROOT);
boolean channelChangedNow = !Objects.equals(channelCode, order.getChannelCode())
|| !Objects.equals(channelName, order.getChannelName());
if (channelChangedNow) {
pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(), channelCode,
order.getDestinationType());
}
if (order.getStatus() == OrderStatus.APPROVED) requireReversibleFulfillment(tenantId, orderId);
List<OrderDeclarationDtos.FieldChange> changes = new ArrayList<>();
change(changes, null, "customerRemark", order.getCustomerRemark(), request.customerRemark());
change(changes, null, "expectedArrivalAt", order.getExpectedArrivalAt(), request.expectedArrivalAt());
change(changes, null, "channelCode", order.getChannelCode(), channelCode);
change(changes, null, "channelName", order.getChannelName(), channelName);
for (OrderDeclarationDtos.Cargo update : request.cargoes()) {
OrderCargoEntity cargo = byItemNo.get(update.itemNo());
collect(changes, cargo, update);
}
if (changes.isEmpty()) throw new BizException(ResultCode.CONFLICT, "申报资料没有发生变化");
boolean changedBefore = Boolean.TRUE.equals(order.getChannelChanged());
boolean channelChanged = changedBefore || channelChangedNow;
if (repository.updateDeclarationHeader(tenantId, orderId, request.expectedVersion(), operatorId,
request.customerRemark(), request.expectedArrivalAt(), channelCode, channelName, channelChanged) != 1) {
throw new BizException(ResultCode.CONFLICT, "订单版本已变化,请刷新详情后重试");
}
for (OrderDeclarationDtos.Cargo update : request.cargoes()) {
OrderCargoEntity cargo = byItemNo.get(update.itemNo());
apply(cargo, update, operatorId);
if (repository.updateDeclarationCargo(tenantId, orderId, cargo) != 1) {
throw new BizException(ResultCode.CONFLICT, "货物资料已变化,请刷新详情后重试");
}
}
int version = request.expectedVersion() + 1;
recordHistory(tenantId, orderId, operatorId, operatorName, order.getStatus(), changes.size());
if (order.getStatus() == OrderStatus.APPROVED) {
writeOutbox(order, operatorId, request, channelCode, channelName, channelChanged, changes, version);
}
return new OrderDeclarationDtos.UpdateResponse(order.getOrderNo(), order.getInboundCode(),
request.expectedVersion(), version, changedBefore, channelChanged, List.copyOf(changes));
}
private void requireReversibleFulfillment(long tenantId, long orderId) {
var row = unifiedOrders.platformDetail(tenantId, orderId).summary();
boolean hasIrreversibleFact = row.measurementCompletedAt() != null
|| row.preload() != null || row.loading() != null || row.coLoadGroup() != null
|| row.feeReviewStatus() != null && !"UNRECORDED".equals(row.feeReviewStatus());
if (hasIrreversibleFact || !"WAIT_MEASURE".equals(row.mainStatus())) {
throw new BizException(ResultCode.CONFLICT,
"订单已产生测量、分柜、装柜或计费事实,不能修改客户申报资料");
}
}
private void collect(List<OrderDeclarationDtos.FieldChange> changes, OrderCargoEntity before,
OrderDeclarationDtos.Cargo after) {
int item = after.itemNo();
change(changes,item,"cargoName",before.getCargoName(),after.cargoName()); change(changes,item,"cargoNameEn",before.getCargoNameEn(),after.cargoNameEn());
change(changes,item,"skuNo",before.getSkuNo(),after.skuNo()); change(changes,item,"model",before.getModel(),after.model());
change(changes,item,"packageCount",before.getPackageCount(),after.packageCount()); change(changes,item,"totalQuantity",before.getTotalQuantity(),after.totalQuantity());
change(changes,item,"lengthCm",before.getLengthCm(),after.lengthCm()); change(changes,item,"widthCm",before.getWidthCm(),after.widthCm());
change(changes,item,"heightCm",before.getHeightCm(),after.heightCm()); change(changes,item,"grossWeightKg",before.getGrossWeightKg(),after.grossWeightKg());
change(changes,item,"volumeCbm",before.getVolumeCbm(),after.volumeCbm()); change(changes,item,"netWeightKg",before.getNetWeightKg(),after.netWeightKg());
change(changes,item,"hsCode",before.getHsCode(),after.hsCode()); change(changes,item,"customsHsCode",before.getCustomsHsCode(),after.customsHsCode());
change(changes,item,"unitsPerPackage",before.getUnitsPerPackage(),after.unitsPerPackage()); change(changes,item,"unitDeclaredValue",before.getUnitDeclaredValue(),after.unitDeclaredValue());
change(changes,item,"declaredValue",before.getDeclaredValue(),after.declaredValue()); change(changes,item,"currency",before.getCurrency(),after.currency());
change(changes,item,"material",before.getMaterial(),after.material()); change(changes,item,"usage",before.getProductUsage(),after.usage());
change(changes,item,"brand",before.getBrand(),after.brand()); change(changes,item,"cargoRemark",before.getCargoRemark(),after.cargoRemark());
}
private void apply(OrderCargoEntity value, OrderDeclarationDtos.Cargo source, long operatorId) {
value.setCargoName(source.cargoName()); value.setCargoNameEn(source.cargoNameEn()); value.setSkuNo(source.skuNo()); value.setModel(source.model());
value.setPackageCount(source.packageCount()); value.setTotalQuantity(source.totalQuantity()); value.setLengthCm(source.lengthCm()); value.setWidthCm(source.widthCm()); value.setHeightCm(source.heightCm());
value.setGrossWeightKg(source.grossWeightKg()); value.setVolumeCbm(source.volumeCbm()); value.setNetWeightKg(source.netWeightKg()); value.setHsCode(source.hsCode()); value.setCustomsHsCode(source.customsHsCode());
value.setUnitsPerPackage(source.unitsPerPackage()); value.setUnitDeclaredValue(source.unitDeclaredValue()); value.setDeclaredValue(source.declaredValue()); value.setCurrency(source.currency());
value.setMaterial(source.material()); value.setProductUsage(source.usage()); value.setBrand(source.brand()); value.setCargoRemark(source.cargoRemark()); value.setUpdatedBy(operatorId); value.setUpdatedAt(LocalDateTime.now());
}
private void recordHistory(long tenantId, long orderId, long operatorId, String operatorName,
OrderStatus status, int changedFields) {
OrderStatusHistoryEntity history = new OrderStatusHistoryEntity();
history.setTenantId(tenantId); history.setOrderId(orderId); history.setFromStatus(status); history.setToStatus(status);
history.setAction("DECLARATION_UPDATE"); history.setReason("平台修改客户申报资料,共 " + changedFields + " 个字段");
history.setOperatorId(operatorId); history.setOperatorName(operatorName); history.setOccurredAt(LocalDateTime.now());
repository.insertHistory(history);
}
private void writeOutbox(OrderEntity order, long operatorId, OrderDeclarationDtos.UpdateRequest request,
String channelCode, String channelName, boolean channelChanged,
List<OrderDeclarationDtos.FieldChange> changes, int version) {
LocalDateTime occurredAt = LocalDateTime.now();
var payload = new OrderDeclarationDtos.UpdatedEvent(1, UUID.randomUUID().toString(), order.getTenantId(),
order.getId(), order.getOrderNo(), order.getInboundCode(), version, operatorId, occurredAt,
request.customerRemark(), request.expectedArrivalAt(), channelCode, channelName, channelChanged,
request.cargoes().stream().sorted(Comparator.comparing(OrderDeclarationDtos.Cargo::itemNo)).toList(),
List.copyOf(changes));
OutboxEventEntity event = new OutboxEventEntity();
event.setTenantId(order.getTenantId()); event.setAggregateType("FIRST_LEG_ORDER"); event.setAggregateId(order.getId());
event.setEventType("ORDER_DECLARATION_UPDATED_V1");
try { event.setPayloadJson(json.writeValueAsString(payload)); }
catch (JsonProcessingException exception) { throw new BizException("Cannot serialize declaration update event"); }
event.setPublishStatus("PENDING"); event.setRetryCount(0); event.setCreatedAt(occurredAt);
repository.insertOutbox(event);
}
private void change(List<OrderDeclarationDtos.FieldChange> changes, Integer itemNo, String field,
Object before, Object after) {
if (!Objects.equals(before, after)) changes.add(new OrderDeclarationDtos.FieldChange(itemNo, field, before, after));
}
private String value(String requested, String current) { return StringUtils.hasText(requested) ? requested.trim() : current; }
}
@@ -5,6 +5,7 @@ import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.order.client.FirstlegInboundClient;
import com.baiyue.order.entity.OutboxEventEntity;
import com.baiyue.order.dto.OrderDeclarationDtos;
import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
@@ -26,8 +27,15 @@ public class OrderInboundOutboxPublisher {
@Scheduled(fixedDelayString = "${baiyue.order.inbound-outbox.fixed-delay-ms:3000}")
public void publishApprovedOrders() {
for (OutboxEventEntity event : repository.pendingInboundEvents(50)) {
if (!"FIRST_LEG_ORDER".equals(event.getAggregateType())
|| !"FIRST_LEG_ORDER_APPROVED".equals(event.getEventType())) {
if (!"FIRST_LEG_ORDER".equals(event.getAggregateType())) {
failPermanently(event, "unsupported order event");
continue;
}
if ("ORDER_DECLARATION_UPDATED_V1".equals(event.getEventType())) {
publishDeclarationUpdate(event);
continue;
}
if (!"FIRST_LEG_ORDER_APPROVED".equals(event.getEventType())) {
failPermanently(event, "unsupported order event");
continue;
}
@@ -73,6 +81,26 @@ public class OrderInboundOutboxPublisher {
}
}
private void publishDeclarationUpdate(OutboxEventEntity event) {
try {
OrderDeclarationDtos.UpdatedEvent command = json.readValue(
event.getPayloadJson(), OrderDeclarationDtos.UpdatedEvent.class);
if (command == null || event.getTenantId() == null || event.getAggregateId() == null
|| event.getTenantId() != command.tenantId()
|| event.getAggregateId() != command.orderId()) {
failPermanently(event, "declaration event ownership does not match payload");
return;
}
ApiResponse<Void> response = client.updateDeclaration(internalToken, command);
if (response == null || !response.successful()) throw new IllegalStateException("头程申报同步失败");
repository.markOutboxPublished(event.getTenantId(), event.getId(), LocalDateTime.now());
} catch (com.fasterxml.jackson.core.JsonProcessingException exception) {
failPermanently(event, "invalid declaration event payload");
} catch (Exception exception) {
repository.markOutboxRetry(event.getTenantId(), event.getId(), LocalDateTime.now().plusSeconds(10));
}
}
private boolean incomplete(FirstlegInboundClient.OrderApprovedInboundCommand command) {
return command.orderNo() == null || command.orderNo().isBlank()
|| command.inboundCode() == null || command.inboundCode().isBlank()
@@ -284,13 +284,13 @@ public class UnifiedOrderQueryService {
order.getStatus() == OrderStatus.SUBMITTED ? "APPROVE_ORDER" : null,
relation(order, childCounts),
order.getStatus(), order.getCustomerId(), order.getCustomerName(),
order.getChannelCode(), order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getChannelCode(), order.getChannelName(), Boolean.TRUE.equals(order.getChannelChanged()), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null,
order.getInboundCode(),
order.getExpectedShipDate(), totalPackageCount(cargo), null, null, grossWeightKg(cargo), volumeCbm(cargo),
order.getExpectedShipDate(), totalPackageCount(cargo), null, null, volumeCbm(cargo), null, null,
null, null, null, null, order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(),
order.getCustomsInspection(), order.getDestinationType(), order.getAmazonWarehouseCode(),
null, null, null, null, netWeightKg(cargo), null, null, order.getExpressCompany(), order.getExpressTrackingNo(),
null, null, null, null, null, null, null, order.getExpressCompany(), order.getExpressTrackingNo(),
StringUtils.hasText(creatorName) ? creatorName : string(order.getCreatedBy()), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo),
null, null, null, null, null, coLoadGroup,
order.getCustomerRemark(), latestInternalNote,
@@ -300,15 +300,15 @@ public class UnifiedOrderQueryService {
return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(),
summary.mainStatus(), summary.nextAction(), relation(order, childCounts), order.getStatus(),
order.getCustomerId(), order.getCustomerName(),
order.getChannelCode(), order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getChannelCode(), order.getChannelName(), Boolean.TRUE.equals(order.getChannelChanged()), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(),
order.getExpectedShipDate(), inbound.totalPackageCount() != null ? inbound.totalPackageCount() : totalPackageCount(cargo), null, inbound.locationCodes(),
inbound.actualGrossWeightKg() != null ? inbound.actualGrossWeightKg() : grossWeightKg(cargo),
inbound.actualVolumeCbm() != null ? inbound.actualVolumeCbm() : volumeCbm(cargo), inbound.expectedArrivalAt(), inbound.feeReviewStatus(),
order.getExpectedShipDate(), inbound.totalPackageCount() != null ? inbound.totalPackageCount() : totalPackageCount(cargo), null, inbound.locationCodes(), volumeCbm(cargo),
inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(),
order.getExpectedArrivalAt() != null ? order.getExpectedArrivalAt() : inbound.expectedArrivalAt(), inbound.feeReviewStatus(),
inbound.companyName(), inbound.businessType(), order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(),
inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(),
inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(),
inbound.actualNetWeightKg() != null ? inbound.actualNetWeightKg() : netWeightKg(cargo),
inbound.actualNetWeightKg(),
inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(),
StringUtils.hasText(creatorName) ? creatorName : inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo), measurement(inbound),
preload(summary.preload()), loading(summary.loading()),
@@ -324,18 +324,17 @@ public class UnifiedOrderQueryService {
return new UnifiedOrderResponse.Row(inbound.orderId(), inbound.tenantId(), inbound.orderNo(),
summary.mainStatus(), summary.nextAction(), relation(order, childCounts),
parseOrderStatus(inbound.orderStatusSnapshot()),
inbound.customerId(), inbound.customerName(), inbound.channelCode(), inbound.channelName(), inbound.warehouseId(),
inbound.customerId(), inbound.customerName(), inbound.channelCode(), inbound.channelName(),
order != null && Boolean.TRUE.equals(order.getChannelChanged()), inbound.warehouseId(),
inbound.warehouseName(), inbound.deliveryWarehouseId(), inbound.deliveryWarehouseName(),
inbound.id(), inbound.version(), inbound.inboundCode(), null,
inbound.totalPackageCount() != null ? inbound.totalPackageCount() : totalPackageCount(cargo),
null, inbound.locationCodes(),
inbound.actualGrossWeightKg() != null ? inbound.actualGrossWeightKg() : grossWeightKg(cargo),
inbound.actualVolumeCbm() != null ? inbound.actualVolumeCbm() : volumeCbm(cargo),
inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(),
inbound.totalPackageCount() != null ? inbound.totalPackageCount() : totalPackageCount(cargo), null, inbound.locationCodes(), volumeCbm(cargo),
inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(),
order != null && order.getExpectedArrivalAt() != null ? order.getExpectedArrivalAt() : inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(),
inbound.businessType(), order == null ? null : order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(),
inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(),
inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(),
inbound.actualNetWeightKg() != null ? inbound.actualNetWeightKg() : netWeightKg(cargo),
inbound.actualNetWeightKg(),
inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(),
inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo),
measurement(inbound), preload(summary.preload()), loading(summary.loading()),
+2
View File
@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓逻辑ID',
destination_warehouse_name VARCHAR(128) NOT NULL COMMENT '目的仓名称快照',
expected_ship_date DATE NULL COMMENT '预计发运日期',
expected_arrival_at DATETIME(3) NULL COMMENT '客户申报预计到货时间',
customer_remark VARCHAR(1000) NULL COMMENT '客户可见备注',
pickup_method VARCHAR(24) NULL COMMENT '取货方式',
requested_pickup_at DATETIME(3) NULL COMMENT '客户要求平台上门提货的预约时间',
@@ -28,6 +29,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
receiving_time VARCHAR(128) NULL COMMENT '国内收货仓收货时间快照',
channel_code VARCHAR(64) NULL COMMENT '渠道编码',
channel_name VARCHAR(128) NULL COMMENT '渠道名称快照',
channel_changed TINYINT(1) NOT NULL DEFAULT 0 COMMENT '订单历史上是否由平台变更过渠道',
carrier_id BIGINT UNSIGNED NULL COMMENT '船司逻辑ID快照',
carrier_name VARCHAR(128) NULL COMMENT '船司名称快照',
tax_mode VARCHAR(24) NULL COMMENT '买单或退税',
@@ -0,0 +1,22 @@
-- 尚未执行。测试库执行前需获得当前轮 SQL 授权。
USE baiyue_order;
ALTER TABLE ord_order
ADD COLUMN expected_arrival_at DATETIME(3) NULL COMMENT '客户申报预计到货时间' AFTER expected_ship_date,
ADD COLUMN channel_changed TINYINT(1) NOT NULL DEFAULT 0 COMMENT '订单历史上是否由平台变更过渠道' AFTER channel_name;
USE baiyue_iam;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
route_path, http_method, sort_no, status, deleted)
VALUES
(5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit',
'修改客户申报资料', NULL, NULL, 121, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), parent_id = VALUES(parent_id),
sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0;
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT role_id, 5102027, 0
FROM (SELECT 4000001 AS role_id UNION ALL SELECT 4000003 AS role_id) roles
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
@@ -0,0 +1,17 @@
-- Order 导出权限初始化源;由统一增量合并后在 IAM 数据库执行。
USE baiyue_iam;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
route_path, http_method, sort_no, status, deleted)
VALUES
(5102026, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:export', '导出订单列表',
NULL, NULL, 120, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id),
sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0;
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT role_id, 5102026, 0
FROM (SELECT 4000001 AS role_id UNION ALL SELECT 4000003 AS role_id) roles
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
+127 -2
View File
@@ -6,6 +6,28 @@ info:
security:
- bearerAuth: []
paths:
/internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches:
post:
security: []
summary: File 异步导出中心分页读取国内仓订单
description: >-
仅接受内部服务令牌。selectedOrderNos 非空时忽略查询筛选并严格按所选业务订单号导出;
selectedOrderNos 为空时按与平台统一订单列表一致的筛选条件导出全部匹配记录,page/size 不属于请求参数。
Provider 只返回结构化数据,不生成文件;调用方创建 File 任务时 exportType 固定为
DOMESTIC_WAREHOUSE_ORDER_LIST、format 固定为 XLSX,并需要 platform:order:export。
parameters:
- name: X-Internal-Service-Token
in: header
required: true
schema: {type: string, minLength: 32}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/DomesticWarehouseOrderExportBatchRequest'}
responses:
'200': {description: 返回固定列定义、当前批数据、游标与首批总数}
'403': {description: 内部服务令牌无效}
/api/merchant/v1/orders/first-leg/addresses:
get:
summary: 查询当前客户下单地址簿
@@ -184,6 +206,25 @@ paths:
responses:
'200': {description: 备注添加成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:note 权限}
/api/platform/v1/orders/{orderId}/declaration:
put:
summary: 版本化修改客户原始申报资料
description: >-
需要 platform:order:declaration:edit。货物使用订单内 itemNo 定位,不接受货物数据库 ID;
不允许修改客户、国内收货仓、订单号、入仓码、状态、仓库/PDA/设备实测、测量审核或计费事实。
审核通过订单仅在尚无测量、同柜组、分柜、装柜或计费事实时受理,并通过
ORDER_DECLARATION_UPDATED_V1 Outbox 快照同步 Firstleg;消费者按 eventId 幂等、version 顺序应用。
parameters:
- $ref: '#/components/parameters/UnifiedOrderId'
- $ref: '#/components/parameters/UnifiedTenantId'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/OrderDeclarationUpdateRequest'}
responses:
'200': {description: 成功返回新版本、渠道历史标记和字段级前后值;版本冲突或存在不可逆履约事实时响应体业务码为 A0409}
'403': {description: 无权限或非平台身份}
/api/merchant/v1/orders:
get:
summary: 客户统一订单列表
@@ -486,6 +527,88 @@ components:
scheme: bearer
bearerFormat: JWT
schemas:
DomesticWarehouseOrderExportBatchRequest:
type: object
required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit]
properties:
taskId: {type: string, pattern: '^[1-9][0-9]*$'}
tenantId: {type: string, enum: ['0'], description: 平台导出固定为0}
appCode: {type: string, enum: [PLATFORM]}
requestedBy: {type: string, pattern: '^[1-9][0-9]*$'}
dispatchVersion: {type: integer, minimum: 1}
parameters: {$ref: '#/components/schemas/DomesticWarehouseOrderExportParameters'}
cursor: {type: string, nullable: true}
snapshotToken: {type: string, nullable: true, maxLength: 512}
limit: {type: integer, minimum: 1, maximum: 500}
DomesticWarehouseOrderExportParameters:
type: object
description: >-
selectedOrderNos 非空时仅导出所选订单;为空或省略时使用其余筛选字段导出全量匹配结果。
筛选字段与 GET /api/platform/v1/orders 一致,但不得传 page、size。
properties:
selectedOrderNos:
type: array
maxItems: 5000
uniqueItems: true
items: {type: string, minLength: 1, maxLength: 64}
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
keyword: {type: string, maxLength: 64}
orderNo: {type: string, maxLength: 64}
mainStatus: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED]}
customerId: {type: string, pattern: '^[1-9][0-9]*$'}
receivingWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
inboundCode: {type: string, maxLength: 64}
internalOrderStatus: {type: string, maxLength: 32}
expectedArrivalDate: {type: string, format: date}
feeReviewStatus: {type: string, enum: [UNRECORDED, PENDING, APPROVED, REJECTED]}
channelName: {type: string, maxLength: 128}
containerNo: {type: string, maxLength: 64}
cabinetNo: {type: string, maxLength: 64}
sailingDate: {type: string, format: date}
loaded: {type: boolean}
fullContainer: {type: boolean}
measurementReviewStatus: {type: string, enum: [UNRECORDED, PENDING, APPROVED, REJECTED]}
fbaCode: {type: string, maxLength: 64}
createdFrom: {type: string, format: date-time}
createdTo: {type: string, format: date-time}
OrderDeclarationUpdateRequest:
type: object
required: [expectedVersion, cargoes]
properties:
expectedVersion: {type: integer, minimum: 0}
customerRemark: {type: string, nullable: true, maxLength: 1000}
expectedArrivalAt: {type: string, format: date-time, nullable: true}
channelCode: {type: string, maxLength: 64, description: 与 channelName 同时传;变更时重新校验价格渠道,入仓码保持不变}
channelName: {type: string, maxLength: 128}
cargoes: {type: array, minItems: 1, maxItems: 500, items: {$ref: '#/components/schemas/OrderDeclarationCargo'}}
OrderDeclarationCargo:
type: object
required: [itemNo, cargoName, packageCount, totalQuantity, lengthCm, widthCm, heightCm, grossWeightKg, volumeCbm]
properties:
itemNo: {type: integer, minimum: 1, maximum: 500, description: 订单内稳定货物行号}
cargoName: {type: string, maxLength: 128}
cargoNameEn: {type: string, nullable: true, maxLength: 128}
skuNo: {type: string, nullable: true, maxLength: 64}
model: {type: string, nullable: true, maxLength: 64}
packageCount: {type: integer, minimum: 1}
totalQuantity: {type: integer, minimum: 1}
lengthCm: {type: number, minimum: 0.01}
widthCm: {type: number, minimum: 0.01}
heightCm: {type: number, minimum: 0.01}
grossWeightKg: {type: number, minimum: 0.001}
volumeCbm: {type: number, minimum: 0.0001}
netWeightKg: {type: number, nullable: true, minimum: 0.001}
hsCode: {type: string, nullable: true, maxLength: 20}
customsHsCode: {type: string, nullable: true, maxLength: 20}
unitsPerPackage: {type: integer, nullable: true, minimum: 1}
unitDeclaredValue: {type: number, nullable: true, minimum: 0}
declaredValue: {type: number, nullable: true, minimum: 0}
currency: {type: string, nullable: true, pattern: '^[A-Z]{3}$'}
material: {type: string, nullable: true, maxLength: 128}
usage: {type: string, nullable: true, maxLength: 128}
brand: {type: string, nullable: true, maxLength: 64}
cargoRemark: {type: string, nullable: true, maxLength: 500}
TenantOrderStatisticsResponse:
type: object
required: [tenantId, totalSubmittedOrders, currentMonthSubmittedOrders, monthStart, nextMonthStart, zoneId, asOf]
@@ -549,6 +672,7 @@ components:
customerName: {type: string, nullable: true}
channelCode: {type: string, nullable: true, description: 稳定物流渠道编码,仅供业务校验;直接来自当前 Order 或 Firstleg 权威快照,不由 channelName 推导}
channelName: {type: string, nullable: true}
channelChanged: {type: boolean, description: 当前订单历史上是否由平台变更过渠道}
receivingWarehouseId: {type: integer, format: int64, nullable: true}
receivingWarehouseName: {type: string, nullable: true}
destinationWarehouseId: {type: integer, format: int64, nullable: true}
@@ -560,8 +684,9 @@ components:
totalPackageCount: {type: integer, nullable: true, description: 无入仓事实时为当前订单有效货物箱数合计;存在入仓业务单元后为该单元履约总箱数,拆分后不以父单原始箱数覆盖}
recommendedPalletType: {type: string, nullable: true, description: 当前无列表级权威事实时返回 null,不得返回固定托盘类型}
locationCodes: {type: string, nullable: true}
actualGrossWeightKg: {type: number, nullable: true, description: Firstleg 实测毛重;来源 actualGrossWeightKg,不以净重回退}
actualVolumeCbm: {type: number, nullable: true}
expectedVolumeCbm: {type: number, nullable: true, description: Order 客户申报货物体积合计,不得与实测体积混用}
actualGrossWeightKg: {type: number, nullable: true, description: 仅 Firstleg 实测毛重;无实测时返回 null}
actualVolumeCbm: {type: number, nullable: true, description: 仅 Firstleg 实测体积;无实测时返回 null}
expectedArrivalAt: {type: string, format: date-time, nullable: true}
feeReviewStatus: {type: string, nullable: true, description: Firstleg 入仓费用审核快照;财务状态不在本字段表达}
companyName: {type: string, nullable: true}
@@ -0,0 +1,41 @@
package com.baiyue.order.controller;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.yaml.snakeyaml.Yaml;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class DomesticWarehouseOrderExportOpenApiContractTest {
@Test
void documentsAsyncProviderSelectionPrecedenceAndListFilters() throws Exception {
String source = new ClassPathResource("openapi/order-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = map(new Yaml().load(source));
Map<String, Object> paths = map(document.get("paths"));
Map<String, Object> operation = map(map(paths.get(
"/internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches")).get("post"));
assertThat(operation.get("description").toString())
.contains("selectedOrderNos 非空时忽略查询筛选", "不生成文件",
"DOMESTIC_WAREHOUSE_ORDER_LIST", "platform:order:export");
Map<String, Object> schemas = map(map(document.get("components")).get("schemas"));
Map<String, Object> parameters = map(map(
schemas.get("DomesticWarehouseOrderExportParameters")).get("properties"));
assertThat(parameters).containsKeys(
"selectedOrderNos", "tenantId", "keyword", "orderNo", "mainStatus", "customerId",
"receivingWarehouseId", "destinationWarehouseId", "inboundCode", "internalOrderStatus",
"expectedArrivalDate", "feeReviewStatus", "channelName", "containerNo", "cabinetNo",
"sailingDate", "loaded", "fullContainer", "measurementReviewStatus", "fbaCode",
"createdFrom", "createdTo");
assertThat(parameters).doesNotContainKeys("page", "size", "orderId", "customerInternalId");
}
@SuppressWarnings("unchecked")
private Map<String, Object> map(Object value) {
return (Map<String, Object>) value;
}
}
@@ -0,0 +1,43 @@
package com.baiyue.order.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
import com.baiyue.order.service.DomesticWarehouseOrderExportProviderService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(DomesticWarehouseOrderExportProviderController.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=0123456789abcdef0123456789abcdef")
class DomesticWarehouseOrderExportProviderControllerTest {
private static final String TOKEN = "0123456789abcdef0123456789abcdef";
@MockBean private DomesticWarehouseOrderExportProviderService service;
@Resource private DomesticWarehouseOrderExportProviderController controller;
@Test
void acceptsOnlyConfiguredInternalServiceToken() {
ExportBatchRequest request = new ExportBatchRequest(
"1001", "0", "PLATFORM", "7001", 1, Map.of(), null, null, 100);
ExportBatchResponse response = new ExportBatchResponse(
1, "hash", "snapshot", List.of(), List.of(), null, false, 0L);
when(service.exportBatch(request)).thenReturn(response);
assertThat(controller.batch(TOKEN, request).data()).isSameAs(response);
verify(service).exportBatch(request);
assertThatThrownBy(() -> controller.batch("wrong-token", request))
.isInstanceOf(BizException.class)
.hasMessageContaining("Invalid internal service token");
}
}
@@ -0,0 +1,52 @@
package com.baiyue.order.controller;
import com.baiyue.order.dto.OrderDeclarationDtos;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PutMapping;
import org.yaml.snakeyaml.Yaml;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class OrderDeclarationUpdateContractTest {
@Test
void endpointUsesDedicatedPermissionAndVersionedDto() throws Exception {
var method = PlatformUnifiedOrderController.class.getDeclaredMethod("updateDeclaration",
com.baiyue.common.security.AuthenticatedUser.class, long.class, long.class,
OrderDeclarationDtos.UpdateRequest.class);
assertThat(method.getAnnotation(PutMapping.class).value()).containsExactly("/{orderId}/declaration");
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:order:declaration:edit')");
assertThat(OrderDeclarationDtos.UpdateRequest.class.getRecordComponents())
.extracting(component -> component.getName())
.containsExactly("expectedVersion", "customerRemark", "expectedArrivalAt",
"channelCode", "channelName", "cargoes");
assertThat(OrderDeclarationDtos.Cargo.class.getRecordComponents())
.extracting(component -> component.getName())
.contains("itemNo", "cargoName", "cargoNameEn", "skuNo", "model", "packageCount",
"totalQuantity", "lengthCm", "widthCm", "heightCm", "grossWeightKg", "volumeCbm")
.doesNotContain("id", "orderId", "actualGrossWeightKg", "actualVolumeCbm",
"measurementReviewStatus", "feeReviewStatus");
}
@Test
void openApiSeparatesDeclaredAndMeasuredFacts() throws Exception {
String source = new ClassPathResource("openapi/order-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = map(new Yaml().load(source));
assertThat(map(document.get("paths")))
.containsKey("/api/platform/v1/orders/{orderId}/declaration");
Map<String, Object> schemas = map(map(document.get("components")).get("schemas"));
Map<String, Object> row = map(map(schemas.get("UnifiedOrderRow")).get("properties"));
assertThat(row).containsKeys("expectedVolumeCbm", "actualVolumeCbm", "expectedArrivalAt", "channelChanged");
assertThat(map(row.get("actualVolumeCbm")).get("description").toString()).contains("仅 Firstleg 实测");
assertThat(source).contains("ORDER_DECLARATION_UPDATED_V1", "platform:order:declaration:edit", "A0409");
}
@SuppressWarnings("unchecked")
private Map<String, Object> map(Object value) { return (Map<String, Object>) value; }
}
@@ -1,14 +1,17 @@
package com.baiyue.order.repository;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.entity.OrderIdempotencyRecordEntity;
import com.baiyue.order.entity.OutboxEventEntity;
import com.baiyue.order.mapper.OrderCargoMapper;
import com.baiyue.order.mapper.OrderIdempotencyRecordMapper;
import com.baiyue.order.mapper.OrderMapper;
import com.baiyue.order.mapper.OutboxEventMapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
@@ -32,6 +35,7 @@ class OrderRepositoryIsolationUpdateTest {
@BeforeAll
static void initializeTableMetadata() {
initialize(OrderEntity.class, "order-update-test");
initialize(OrderCargoEntity.class, "order-cargo-update-test");
initialize(OrderIdempotencyRecordEntity.class, "idempotency-update-test");
initialize(OutboxEventEntity.class, "outbox-update-test");
}
@@ -66,6 +70,29 @@ class OrderRepositoryIsolationUpdateTest {
verifyNoInteractions(mapper);
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void declarationCargoUpdateIsVersionedScopedAndCanExplicitlyClearOptionalFields() {
OrderCargoMapper mapper = mock(OrderCargoMapper.class);
OrderRepository repository = repository("cargoMapper", mapper);
OrderCargoEntity cargo = new OrderCargoEntity();
cargo.setId(31L);
cargo.setVersion(4);
cargo.setCargoName("Keyboard");
cargo.setCargoRemark(null);
when(mapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
repository.updateDeclarationCargo(11L, 21L, cargo);
ArgumentCaptor<Wrapper<OrderCargoEntity>> wrapper = ArgumentCaptor.forClass(Wrapper.class);
verify(mapper).update(isNull(), wrapper.capture());
assertThat(wrapper.getValue().getSqlSegment())
.contains("tenant_id", "order_id", "id", "version", "deleted");
assertThat(((LambdaUpdateWrapper<?>) wrapper.getValue()).getSqlSet())
.contains("cargo_remark", "version = version + 1");
assertThat(params(wrapper.getValue()).values()).contains(11L, 21L, 31L, 4, false, "Keyboard", null);
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void idempotencyCompletionIsLimitedByCustomerRecordAndProcessingState() {
@@ -0,0 +1,146 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
import com.baiyue.order.dto.UnifiedOrderQuery;
import com.baiyue.order.dto.UnifiedOrderResponse;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(DomesticWarehouseOrderExportProviderService.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=0123456789abcdef0123456789abcdef")
class DomesticWarehouseOrderExportProviderServiceTest {
@MockBean private UnifiedOrderQueryService orders;
@Resource private DomesticWarehouseOrderExportProviderService service;
@Test
void nonEmptySelectionExportsOnlySelectedBusinessOrdersAndIgnoresFilters() {
UnifiedOrderResponse.Row first = row("ORD-2", "RK-2", "客户乙");
UnifiedOrderResponse.Row second = row("ORD-1", "RK-1", "客户甲");
when(orders.platformRowsByOrderNos(List.of("ORD-2", "ORD-1")))
.thenReturn(List.of(first, second));
Map<String, Object> parameters = new LinkedHashMap<>();
parameters.put("selectedOrderNos", List.of("ORD-2", "ORD-1"));
parameters.put("mainStatus", "PENDING_REVIEW");
ExportBatchResponse response = service.exportBatch(request("1001", parameters, null, null, 500));
assertThat(response.totalRows()).isEqualTo(2);
assertThat(response.rows()).extracting(row -> row.get("orderNo"))
.containsExactly("ORD-2", "ORD-1");
assertThat(response.rows().get(0)).containsEntry("inboundCode", "RK-2")
.containsEntry("customerName", "客户乙")
.doesNotContainKeys("orderId", "tenantId", "customerId", "inboundId",
"receivingWarehouseId", "destinationWarehouseId");
assertThat(response.columns()).extracting(column -> column.key())
.contains("expectedVolumeCbm", "expectedArrivalAt", "channelChanged")
.doesNotContain("orderId", "tenantId", "customerId", "inboundId");
verify(orders, never()).platformPage(any(), any());
}
@Test
void emptySelectionExportsAllMatchingFiltersWithProviderPaging() {
UnifiedOrderResponse.Row row = row("ORD-3", "RK-3", "客户丙");
when(orders.platformPage(eq(9001L), any()))
.thenReturn(new PageResult<>(List.of(row), 1, 500, 501, 2));
Map<String, Object> parameters = new LinkedHashMap<>();
parameters.put("selectedOrderNos", List.of());
parameters.put("tenantId", "9001");
parameters.put("keyword", "RK-3");
parameters.put("loaded", true);
parameters.put("createdFrom", "2026-08-01T00:00:00");
ExportBatchResponse response = service.exportBatch(request("1002", parameters, null, null, 500));
ArgumentCaptor<UnifiedOrderQuery> query = ArgumentCaptor.forClass(UnifiedOrderQuery.class);
verify(orders).platformPage(eq(9001L), query.capture());
assertThat(query.getValue().keyword()).isEqualTo("RK-3");
assertThat(query.getValue().loaded()).isTrue();
assertThat(query.getValue().page()).isEqualTo(1);
assertThat(query.getValue().size()).isEqualTo(500);
assertThat(response.totalRows()).isEqualTo(501);
assertThat(response.hasMore()).isTrue();
assertThat(response.nextCursor()).isEqualTo("2");
}
@Test
void selectionMustBeUniqueAndEntirelyVisible() {
assertThatThrownBy(() -> service.exportBatch(request("1003",
Map.of("selectedOrderNos", List.of("ORD-1", "ORD-1")), null, null, 100)))
.isInstanceOf(BizException.class)
.hasMessageContaining("不能重复");
UnifiedOrderResponse.Row visible = row("ORD-1", "RK-1", "客户甲");
when(orders.platformRowsByOrderNos(List.of("ORD-1", "ORD-MISSING")))
.thenReturn(List.of(visible));
assertThatThrownBy(() -> service.exportBatch(request("1004",
Map.of("selectedOrderNos", List.of("ORD-1", "ORD-MISSING")), null, null, 100)))
.isInstanceOf(BizException.class)
.hasMessageContaining("不存在或不可见");
}
@Test
void filteredExportRejectsEmptyAndOverLimitResults() {
when(orders.platformPage(any(), any()))
.thenReturn(new PageResult<>(List.of(), 1, 100, 0, 0));
assertThatThrownBy(() -> service.exportBatch(request("1005", Map.of(), null, null, 100)))
.isInstanceOf(BizException.class)
.hasMessageContaining("没有符合");
when(orders.platformPage(any(), any()))
.thenReturn(new PageResult<>(List.of(), 1, 100, 200_001, 2001));
assertThatThrownBy(() -> service.exportBatch(request("1006", Map.of(), null, null, 100)))
.isInstanceOf(BizException.class)
.hasMessageContaining("超过最大限制");
}
@Test
void continuationSnapshotCannotBeReusedForDifferentTaskOrParameters() {
UnifiedOrderResponse.Row row = row("ORD-1", "RK-1", "客户甲");
when(orders.platformPage(any(), any()))
.thenReturn(new PageResult<>(List.of(row), 1, 1, 2, 2));
ExportBatchResponse first = service.exportBatch(request("1007", Map.of("keyword", "A"), null, null, 1));
assertThatThrownBy(() -> service.exportBatch(
request("1008", Map.of("keyword", "A"), "2", first.snapshotToken(), 1)))
.isInstanceOf(BizException.class)
.hasMessageContaining("does not match");
assertThatThrownBy(() -> service.exportBatch(
request("1007", Map.of("keyword", "B"), "2", first.snapshotToken(), 1)))
.isInstanceOf(BizException.class)
.hasMessageContaining("does not match");
}
private ExportBatchRequest request(String taskId, Map<String, Object> parameters,
String cursor, String snapshot, int limit) {
return new ExportBatchRequest(taskId, "0", "PLATFORM", "7001", 1,
parameters, cursor, snapshot, limit);
}
private UnifiedOrderResponse.Row row(String orderNo, String inboundCode, String customerName) {
UnifiedOrderResponse.Row row = mock(UnifiedOrderResponse.Row.class);
when(row.orderNo()).thenReturn(orderNo);
when(row.inboundCode()).thenReturn(inboundCode);
when(row.customerName()).thenReturn(customerName);
return row;
}
}
@@ -0,0 +1,148 @@
package com.baiyue.order.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.OrderDeclarationDtos;
import com.baiyue.order.dto.UnifiedOrderResponse;
import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(OrderDeclarationUpdateService.class)
class OrderDeclarationUpdateServiceTest {
@MockBean private OrderRepository repository;
@MockBean private OrderPricingChannelGuard pricingChannelGuard;
@MockBean private UnifiedOrderQueryService unifiedOrders;
@MockBean private ObjectMapper json;
@Resource private OrderDeclarationUpdateService service;
@Test
void updatesCompleteCustomerDeclarationByItemNoAndKeepsInboundCode() {
OrderEntity order = order(OrderStatus.SUBMITTED, 3);
OrderCargoEntity cargo = cargo();
when(repository.findOwned(100L, 200L)).thenReturn(order);
when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo));
when(repository.updateDeclarationHeader(eq(100L), eq(200L), eq(3), eq(700L),
any(), any(), anyString(), anyString(), anyBoolean())).thenReturn(1);
when(repository.updateDeclarationCargo(eq(100L), eq(200L), any())).thenReturn(1);
var request = request(3, "NEW", "新渠道", "新备注");
var response = service.update(100L, 200L, 700L, "平台操作员", request);
assertThat(response.orderNo()).isEqualTo("ORD-200");
assertThat(response.inboundCode()).isEqualTo("RK-LOCKED");
assertThat(response.version()).isEqualTo(4);
assertThat(response.channelChanged()).isTrue();
assertThat(response.changes()).extracting(OrderDeclarationDtos.FieldChange::field)
.contains("channelCode", "channelName", "customerRemark", "expectedArrivalAt",
"cargoName", "packageCount", "totalQuantity", "lengthCm", "grossWeightKg");
verify(pricingChannelGuard).requireAvailable(100L, 31L, "NEW", "FBA");
verify(repository).insertHistory(any());
verify(repository, never()).insertOutbox(any());
assertThat(cargo.getPackageCount()).isEqualTo(20);
assertThat(cargo.getTotalQuantity()).isEqualTo(200);
assertThat(cargo.getLengthCm()).isEqualByComparingTo("60");
}
@Test
void rejectsStaleVersionBeforeAnyWrite() {
when(repository.findOwned(100L, 200L)).thenReturn(order(OrderStatus.SUBMITTED, 4));
assertThatThrownBy(() -> service.update(100L, 200L, 700L, "平台操作员",
request(3, "OLD", "旧渠道", "备注")))
.isInstanceOf(BizException.class).hasMessageContaining("版本已变化");
verify(repository, never()).updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(),
any(), any(), any(), any(), anyBoolean());
}
@Test
void approvedOrderWithMeasurementFactIsAtomicallyRejected() {
OrderEntity order = order(OrderStatus.APPROVED, 3);
when(repository.findOwned(100L, 200L)).thenReturn(order);
when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo()));
UnifiedOrderResponse.Detail detail = org.mockito.Mockito.mock(UnifiedOrderResponse.Detail.class);
UnifiedOrderResponse.Row row = org.mockito.Mockito.mock(UnifiedOrderResponse.Row.class);
when(detail.summary()).thenReturn(row);
when(row.mainStatus()).thenReturn("WAIT_MEASURE");
when(row.measurementCompletedAt()).thenReturn(LocalDateTime.of(2026, 9, 1, 12, 0));
when(unifiedOrders.platformDetail(100L, 200L)).thenReturn(detail);
assertThatThrownBy(() -> service.update(100L, 200L, 700L, "平台操作员",
request(3, "OLD", "旧渠道", "备注")))
.isInstanceOf(BizException.class).hasMessageContaining("测量、分柜、装柜或计费事实");
verify(repository, never()).updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(),
any(), any(), any(), any(), anyBoolean());
}
@Test
void reversibleApprovedUpdateProducesVersionedOutboxSnapshot() throws Exception {
OrderEntity order = order(OrderStatus.APPROVED, 3);
when(repository.findOwned(100L, 200L)).thenReturn(order);
when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo()));
when(repository.updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(), any(), any(),
any(), any(), anyBoolean())).thenReturn(1);
when(repository.updateDeclarationCargo(anyLong(), anyLong(), any())).thenReturn(1);
UnifiedOrderResponse.Detail detail = org.mockito.Mockito.mock(UnifiedOrderResponse.Detail.class);
UnifiedOrderResponse.Row row = org.mockito.Mockito.mock(UnifiedOrderResponse.Row.class);
when(detail.summary()).thenReturn(row);
when(row.mainStatus()).thenReturn("WAIT_MEASURE");
when(row.feeReviewStatus()).thenReturn("UNRECORDED");
when(unifiedOrders.platformDetail(100L, 200L)).thenReturn(detail);
when(json.writeValueAsString(any())).thenReturn("{\"schemaVersion\":1}");
service.update(100L, 200L, 700L, "平台操作员", request(3, "OLD", "旧渠道", "新备注"));
verify(repository).insertOutbox(any());
}
private OrderEntity order(OrderStatus status, int version) {
OrderEntity order = new OrderEntity();
order.setId(200L); order.setTenantId(100L); order.setOrderNo("ORD-200");
order.setInboundCode("RK-LOCKED"); order.setStatus(status); order.setVersion(version);
order.setReceivingWarehouseId(31L); order.setDestinationType("FBA");
order.setChannelCode("OLD"); order.setChannelName("旧渠道"); order.setChannelChanged(false);
order.setCustomerRemark("旧备注");
return order;
}
private OrderCargoEntity cargo() {
OrderCargoEntity value = new OrderCargoEntity();
value.setId(300L); value.setTenantId(100L); value.setOrderId(200L); value.setItemNo(1); value.setVersion(2);
value.setCargoName("旧品名"); value.setCargoNameEn("Old"); value.setSkuNo("SKU-OLD"); value.setModel("M1");
value.setPackageCount(10); value.setTotalQuantity(100); value.setLengthCm(new BigDecimal("50"));
value.setWidthCm(new BigDecimal("40")); value.setHeightCm(new BigDecimal("30"));
value.setGrossWeightKg(new BigDecimal("100")); value.setVolumeCbm(new BigDecimal("1.0"));
return value;
}
private OrderDeclarationDtos.UpdateRequest request(int version, String channelCode,
String channelName, String remark) {
var cargo = new OrderDeclarationDtos.Cargo(1, "新品名", "New", "SKU-NEW", "M2",
20, 200, new BigDecimal("60"), new BigDecimal("50"), new BigDecimal("40"),
new BigDecimal("120"), new BigDecimal("1.2"), new BigDecimal("110"),
"8518", "851821", 10, new BigDecimal("5"), new BigDecimal("1000"),
"USD", "塑料", "音响", "Brand", "新备注");
return new OrderDeclarationDtos.UpdateRequest(version, remark,
LocalDateTime.of(2026, 9, 15, 10, 30), channelCode, channelName, List.of(cargo));
}
}
@@ -129,6 +129,22 @@ class OrderInboundOutboxPublisherTest {
verify(fixture.repository, never()).markOutboxFailed(any(), anyLong());
}
@Test
void declarationUpdateEventIsDeliveredByVersionedContract() {
String payload = "{\"schemaVersion\":1,\"eventId\":\"evt-1\",\"tenantId\":11,"
+ "\"orderId\":21,\"orderNo\":\"ORD-21\",\"inboundCode\":\"RK-21\","
+ "\"version\":4,\"operatorId\":7,\"channelCode\":\"SEA\","
+ "\"channelName\":\"海运\",\"channelChanged\":true,\"cargoes\":[],\"changes\":[]}";
Fixture fixture = fixture(event("ORDER_DECLARATION_UPDATED_V1", 11L, 21L, payload));
when(fixture.client.updateDeclaration(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
fixture.publisher.publishApprovedOrders();
verify(fixture.client).updateDeclaration(eq("internal-token"), any());
verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class));
verify(fixture.client, never()).create(any(), any());
}
private Fixture fixture(OutboxEventEntity event) {
return fixture(event, "BUY_DECLARATION");
}
@@ -98,9 +98,10 @@ class UnifiedOrderQueryServiceTest {
assertThat(service.page(10001L, query).records()).singleElement().satisfies(row -> {
assertThat(row.totalPackageCount()).isEqualTo(100);
assertThat(row.actualGrossWeightKg()).isEqualByComparingTo("12.5");
assertThat(row.totalNetWeightKg()).isEqualByComparingTo("11.2");
assertThat(row.actualVolumeCbm()).isEqualByComparingTo("0.375");
assertThat(row.expectedVolumeCbm()).isEqualByComparingTo("0.375");
assertThat(row.actualGrossWeightKg()).isNull();
assertThat(row.totalNetWeightKg()).isNull();
assertThat(row.actualVolumeCbm()).isNull();
});
verify(orders).findCargoByOrderIds(10001L, List.of(77L));
}