diff --git a/config/nacos/cn/file-service.yml b/config/nacos/cn/file-service.yml index 40c954665..1f230df04 100644 --- a/config/nacos/cn/file-service.yml +++ b/config/nacos/cn/file-service.yml @@ -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 diff --git a/config/nacos/local/file-service.yml b/config/nacos/local/file-service.yml index c913b54f2..1ecfc0ed3 100644 --- a/config/nacos/local/file-service.yml +++ b/config/nacos/local/file-service.yml @@ -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 diff --git a/file/src/main/java/com/baiyue/file/service/ExportSchemaContracts.java b/file/src/main/java/com/baiyue/file/service/ExportSchemaContracts.java index 9b0dded1a..d3e76f9ea 100644 --- a/file/src/main/java/com/baiyue/file/service/ExportSchemaContracts.java +++ b/file/src/main/java/com/baiyue/file/service/ExportSchemaContracts.java @@ -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 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 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 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 columns, List 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); } } } diff --git a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java index 59fc84765..22071a29a 100644 --- a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java +++ b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java @@ -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 parameters) { + private void validateParameters(String type, Map parameters) { Map 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, "导出筛选条件无法序列化"); } diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index c609212f6..d142be68e 100644 --- a/file/src/main/resources/application.yml +++ b/file/src/main/resources/application.yml @@ -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 diff --git a/file/src/main/resources/openapi/domestic-order-export-v1.yaml b/file/src/main/resources/openapi/domestic-order-export-v1.yaml new file mode 100644 index 000000000..b29e1b41b --- /dev/null +++ b/file/src/main/resources/openapi/domestic-order-export-v1.yaml @@ -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: 创建时间} diff --git a/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java b/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java new file mode 100644 index 000000000..38fb236eb --- /dev/null +++ b/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java @@ -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 document = map(new Yaml().load(source)); + Map schemas = map(map(document.get("components")).get("schemas")); + Map parameters = map(map(schemas.get("DomesticOrderExportParameters")).get("properties")); + Map 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 map(Object value) { return (Map) value; } +} diff --git a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java index 3e81e1be6..b9347a7ba 100644 --- a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java @@ -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 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(); + 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 permissions) { return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", permissions); } diff --git a/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java b/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java index 0e551932f..d983ad4ba 100644 --- a/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java +++ b/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java @@ -136,6 +136,58 @@ class GenericXlsxExportRunnerTest { } } + @Test void domesticOrderProviderMustReturnTheFrozenBusinessColumnsWithoutInternalIds() throws Exception { + List 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 columns = ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_COLUMNS.stream() + .map(column -> new ExportColumn(column.key(), column.title(), column.type(), 18)).toList(); + Map 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 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 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 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(); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java index 6ea5847c8..a4893f879 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java @@ -44,11 +44,34 @@ public final class InboundMeasurementResponses { BigDecimal actualVolumeCbm, BigDecimal volumeWeightKg, String actualGoodsName, + @Schema(description = "Order 服务中的最新客户申报,只用于和既有实测事实对比;不得回写测量、箱码或审核结果") + DeclarationSnapshot latestDeclaration, List items, List weightResults ) { } + public record DeclarationSnapshot( + String referenceId, + Integer totalPackageCount, + BigDecimal grossWeightKg, + BigDecimal volumeCbm, + String goodsName, + List 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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java index bb9c3b274..506f7c6d6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index 3d4729b41..d6610dbbd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index d833afbbc..079ab913f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -69,9 +69,8 @@ public class InboundMeasurementService { ? List.of() : repository.listItems(tenantId, measurement.getId()); List 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 weightPalletOptions( @@ -416,7 +415,8 @@ public class InboundMeasurementService { private InboundMeasurementResponses.Detail toDetail( InboundOrderEntity inbound, InboundMeasurementEntity measurement, List items, List 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 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 loadDeclarationCargoes(InboundOrderEntity inbound) { + try { + ApiResponse> 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) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 9072bb1a1..399c08cc2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -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(); } diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 164ec4e0a..676a2c63c 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java index ce62549c7..93537fe22 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java @@ -24,7 +24,14 @@ class InboundMeasurementEvidenceOpenApiContractTest { Map schemas = stringMap(stringMap(document.get("components")).get("schemas")); Map 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"))) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java index 6cd5052e8..83114096f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -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}") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index 0d43b500d..38c2be50e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -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"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 18ac003b2..16b7e7409 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -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) { diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java index e61647e36..9e1a1b462 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java @@ -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 updateDeclaration(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody OrderDeclarationDtos.UpdatedEvent event); + record InboundReferenceRequest(long tenantId, List orderIds) { } diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java index 705ecf92e..0912d7618 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java @@ -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 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 = "父子订单与柜次数量摘要") diff --git a/order/src/main/java/com/baiyue/order/dto/OrderDeclarationDtos.java b/order/src/main/java/com/baiyue/order/dto/OrderDeclarationDtos.java new file mode 100644 index 000000000..d05bfdfee --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/OrderDeclarationDtos.java @@ -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 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 cargoes, List changes) {} +} diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java index 4aded5a9c..70ce132e3 100644 --- a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java @@ -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, diff --git a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java index 0c8fceec5..79782f8d4 100644 --- a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java +++ b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java @@ -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; diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index 3a2da6fa2..0bf9cbe27 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -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() + .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() + .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); } diff --git a/order/src/main/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderService.java b/order/src/main/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderService.java index 7597e0856..30eff5d11 100644 --- a/order/src/main/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderService.java +++ b/order/src/main/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderService.java @@ -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 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()); diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 48adfb3e5..92a842d62 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -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(), diff --git a/order/src/main/java/com/baiyue/order/service/OrderDeclarationUpdateService.java b/order/src/main/java/com/baiyue/order/service/OrderDeclarationUpdateService.java new file mode 100644 index 000000000..5e048ae89 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/OrderDeclarationUpdateService.java @@ -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 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 current = repository.findCargo(tenantId, orderId); + Map byItemNo = new HashMap<>(); + current.forEach(item -> byItemNo.put(item.getItemNo(), item)); + Set 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 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 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 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 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; } +} diff --git a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java index 276cf75d1..64417f6a0 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java +++ b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java @@ -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 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() diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index dc12fedd0..24e2dadb3 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -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()), diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index 281a1eaed..91651e923 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -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 '买单或退税', diff --git a/order/src/main/resources/db/platform-order-declaration-edit.sql b/order/src/main/resources/db/platform-order-declaration-edit.sql new file mode 100644 index 000000000..6674fca18 --- /dev/null +++ b/order/src/main/resources/db/platform-order-declaration-edit.sql @@ -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); diff --git a/order/src/main/resources/db/platform-order-export-permission.sql b/order/src/main/resources/db/platform-order-export-permission.sql new file mode 100644 index 000000000..b273a4af6 --- /dev/null +++ b/order/src/main/resources/db/platform-order-export-permission.sql @@ -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); diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index c278f3f0f..c51731040 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -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} diff --git a/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportOpenApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportOpenApiContractTest.java new file mode 100644 index 000000000..61191bb3f --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportOpenApiContractTest.java @@ -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 document = map(new Yaml().load(source)); + Map paths = map(document.get("paths")); + Map 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 schemas = map(map(document.get("components")).get("schemas")); + Map 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 map(Object value) { + return (Map) value; + } +} diff --git a/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportProviderControllerTest.java b/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportProviderControllerTest.java new file mode 100644 index 000000000..65ee2cc4a --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportProviderControllerTest.java @@ -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"); + } +} diff --git a/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java b/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java new file mode 100644 index 000000000..49094568d --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java @@ -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 document = map(new Yaml().load(source)); + assertThat(map(document.get("paths"))) + .containsKey("/api/platform/v1/orders/{orderId}/declaration"); + Map schemas = map(map(document.get("components")).get("schemas")); + Map 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 map(Object value) { return (Map) value; } +} diff --git a/order/src/test/java/com/baiyue/order/repository/OrderRepositoryIsolationUpdateTest.java b/order/src/test/java/com/baiyue/order/repository/OrderRepositoryIsolationUpdateTest.java index a18329cde..cd3e273a4 100644 --- a/order/src/test/java/com/baiyue/order/repository/OrderRepositoryIsolationUpdateTest.java +++ b/order/src/test/java/com/baiyue/order/repository/OrderRepositoryIsolationUpdateTest.java @@ -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 = 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() { diff --git a/order/src/test/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderServiceTest.java b/order/src/test/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderServiceTest.java new file mode 100644 index 000000000..2a8c04772 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderServiceTest.java @@ -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 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 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 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 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; + } +} diff --git a/order/src/test/java/com/baiyue/order/service/OrderDeclarationUpdateServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderDeclarationUpdateServiceTest.java new file mode 100644 index 000000000..bfb7fc338 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/OrderDeclarationUpdateServiceTest.java @@ -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)); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java b/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java index d49e02bd0..ca93490e5 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java @@ -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"); } diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index 18baf4d9b..1df73770b 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -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)); }