Merge remote-tracking branch 'origin/test' into dev
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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, "导出筛选条件无法序列化");
|
||||
}
|
||||
|
||||
@@ -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: 创建时间}
|
||||
+60
@@ -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]
|
||||
|
||||
+8
-1
@@ -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")))
|
||||
|
||||
+5
-1
@@ -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); }
|
||||
|
||||
+5
-1
@@ -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()),
|
||||
|
||||
@@ -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);
|
||||
@@ -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}
|
||||
|
||||
+41
@@ -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;
|
||||
}
|
||||
}
|
||||
+43
@@ -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");
|
||||
}
|
||||
}
|
||||
+52
@@ -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; }
|
||||
}
|
||||
+27
@@ -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() {
|
||||
|
||||
+146
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user