diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index c735d3801..a64511126 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -411,6 +411,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', order_no VARCHAR(32) NOT NULL COMMENT '租户内唯一订单号', + inbound_code VARCHAR(40) NULL COMMENT '客户提交后生成的入仓码,草稿为空', customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID', customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照', status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态', @@ -449,6 +450,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_order_tenant_no (tenant_id, order_no), + UNIQUE KEY uk_order_tenant_inbound_code (tenant_id, inbound_code), KEY idx_order_tenant_status_time (tenant_id, status, created_at), KEY idx_order_tenant_customer (tenant_id, customer_id), KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) @@ -2213,6 +2215,30 @@ PREPARE carrier_name_statement FROM @carrier_name_sql; EXECUTE carrier_name_statement; DEALLOCATE PREPARE carrier_name_statement; +-- 客户提交时由 Order 固定入仓码;审核通过后 Firstleg 沿用该码创建正式入库单。 +SET @inbound_code_exists := ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'ord_order' AND column_name = 'inbound_code' +); +SET @inbound_code_sql := IF(@inbound_code_exists = 0, + 'ALTER TABLE ord_order ADD COLUMN inbound_code VARCHAR(40) NULL COMMENT ''客户提交后生成的入仓码,草稿为空'' AFTER order_no', + 'SELECT 1'); +PREPARE inbound_code_statement FROM @inbound_code_sql; +EXECUTE inbound_code_statement; +DEALLOCATE PREPARE inbound_code_statement; + +SET @inbound_code_index_exists := ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'ord_order' + AND index_name = 'uk_order_tenant_inbound_code' +); +SET @inbound_code_index_sql := IF(@inbound_code_index_exists = 0, + 'ALTER TABLE ord_order ADD UNIQUE KEY uk_order_tenant_inbound_code (tenant_id, inbound_code)', + 'SELECT 1'); +PREPARE inbound_code_index_statement FROM @inbound_code_index_sql; +EXECUTE inbound_code_index_statement; +DEALLOCATE PREPARE inbound_code_index_statement; + -- ============================================================================ -- 来源:firstleg\src\main\resources\db\phase2-inbound-schema.sql -- ============================================================================ @@ -2294,6 +2320,12 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id) ) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照'; +UPDATE baiyue_order.ord_order o +LEFT JOIN baiyue_firstleg.fl_inbound_order i + ON i.tenant_id = o.tenant_id AND i.order_id = o.id AND i.deleted = 0 + SET o.inbound_code = COALESCE(i.inbound_code, CONCAT('RK-', o.id)) + WHERE o.deleted = 0 AND o.status <> 'DRAFT' AND o.inbound_code IS NULL; + CREATE TABLE IF NOT EXISTS fl_inbound_pickup_snapshot ( id BIGINT UNSIGNED NOT NULL COMMENT '提货信息快照ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', @@ -3440,6 +3472,7 @@ CALL unified_assert_table('baiyue_audit', 'sys_audit_log'); CALL unified_assert_table('baiyue_event_bridge', 'bridge_inbox'); CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_id'); CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_name'); +CALL unified_assert_column('baiyue_order', 'ord_order', 'inbound_code'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_measurement_item', 'external_detail_no'); CALL unified_assert_column('baiyue_file', 'file_export_task', 'execution_deadline_at'); @@ -4733,6 +4766,21 @@ DROP PROCEDURE IF EXISTS unified_assert_final_extensions; DELIMITER $$ CREATE PROCEDURE unified_assert_final_extensions() BEGIN + IF (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order' + AND index_name = 'uk_order_tenant_inbound_code') <> 2 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:订单入仓码唯一索引未正确创建'; + END IF; + IF EXISTS ( + SELECT 1 FROM baiyue_order.ord_order + WHERE deleted = 0 + AND ((status = 'DRAFT' AND inbound_code IS NOT NULL) + OR (status <> 'DRAFT' AND inbound_code IS NULL)) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:订单状态与入仓码数据不一致'; + END IF; IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'baiyue_firstleg' AND table_name IN ('fl_preload_plan', 'fl_split_operation', diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java index 71a1caa22..1e71e89d9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -53,7 +53,9 @@ public class InternalInboundOrderController { throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); } if (command.tenantId() <= 0 || command.orderId() <= 0 || command.warehouseId() == null - || command.warehouseId() <= 0 || command.customerName() == null || command.customerName().isBlank()) { + || command.warehouseId() <= 0 || command.customerName() == null || command.customerName().isBlank() + || command.inboundCode() == null || command.inboundCode().isBlank() + || command.inboundCode().length() > 40) { throw new BizException(ResultCode.BAD_REQUEST, "订单入库快照不完整"); } return ApiResponse.success(service.createFromApprovedOrder(command)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java index 7f9a0857a..8d81f3d8e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java @@ -7,6 +7,7 @@ public record OrderApprovedInboundCommand( long tenantId, long orderId, String orderNo, + String inboundCode, Long customerId, String customerName, Long warehouseId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java index 5f51a2aab..619c0168d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java @@ -38,7 +38,7 @@ public class InboundOrderCreationService { inbound.setTenantId(command.tenantId()); inbound.setOrderId(command.orderId()); inbound.setOrderNo(command.orderNo()); - inbound.setInboundCode("RK-" + command.orderId()); + inbound.setInboundCode(command.inboundCode()); inbound.setProcessStatus("WAIT_MEASURE"); inbound.setOrderStatusSnapshot("APPROVED"); inbound.setMeasureReviewStatus("NOT_SUBMITTED"); diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index d237a107b..f1b696cc2 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -6,6 +6,19 @@ info: security: - bearerAuth: [] paths: + /internal/firstleg/v1/inbounds/order-approved: + post: + summary: 订单审核通过后幂等创建入库单 + description: 仅供订单服务调用;入仓码由订单提交时生成,Firstleg 必须原样沿用。承运商可在后续履约或计费前补充。 + security: + - internalServiceToken: [] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/OrderApprovedInboundCommand'} + responses: + '200': {description: 成功或业务失败;相同客户和订单重放返回已有入库单 ID} /api/platform/v1/first-leg/tasks: get: summary: 分页查询履约任务 @@ -224,6 +237,10 @@ components: in: header name: X-Signature description: HMAC-SHA256 小写十六进制签名 + internalServiceToken: + type: apiKey + in: header + name: X-Internal-Service-Token parameters: XTimestamp: name: X-Timestamp @@ -259,6 +276,36 @@ components: in: query schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20} schemas: + OrderApprovedInboundCommand: + type: object + required: [tenantId, orderId, inboundCode, customerName, warehouseId] + properties: + tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: 当前客户隔离 ID} + orderId: {type: string, pattern: '^[1-9][0-9]*$'} + orderNo: {type: string, nullable: true} + inboundCode: {type: string, minLength: 1, maxLength: 40, example: RK-2085308606829346818} + customerId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + customerName: {type: string, minLength: 1} + warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} + warehouseName: {type: string, nullable: true} + deliveryWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + deliveryWarehouseName: {type: string, nullable: true} + expectedArrivalAt: {type: string, format: date-time, nullable: true} + businessType: {type: string, nullable: true} + channelCode: {type: string, nullable: true} + channelName: {type: string, nullable: true} + carrierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 可选,可在后续履约或计费前补充} + carrierName: {type: string, nullable: true} + taxMode: {type: string, nullable: true} + containerMode: {type: string, nullable: true} + customsInspection: {type: boolean, nullable: true} + destinationType: {type: string, nullable: true} + destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + destinationCode: {type: string, nullable: true} + destinationName: {type: string, nullable: true} + publicRemark: {type: string, nullable: true} + expressCompanyName: {type: string, nullable: true} + expressTrackingNo: {type: string, nullable: true} MerchantInboundBillingEnvelope: type: object properties: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java index a24689cdc..2522afe1a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.InboundOrderReference; +import com.baiyue.firstleg.dto.OrderApprovedInboundCommand; import com.baiyue.firstleg.service.InboundOrderCreationService; import com.baiyue.firstleg.service.UnifiedInboundQueryService; import com.baiyue.firstleg.dto.UnifiedInboundQuery; @@ -63,4 +64,28 @@ class InternalInboundOrderReferenceControllerTest { .isInstanceOf(BizException.class); verify(unifiedQueryService).page(request); } + + @Test + void approvedOrderRequiresInboundCode() { + assertThatThrownBy(() -> controller.createFromApprovedOrder( + "internal-token", approvedCommand(" ", null))) + .isInstanceOf(BizException.class); + } + + @Test + void approvedOrderAllowsMissingCarrier() { + OrderApprovedInboundCommand command = approvedCommand("RK-77", null); + when(service.createFromApprovedOrder(command)).thenReturn(88L); + + assertThat(controller.createFromApprovedOrder("internal-token", command).data()).isEqualTo(88L); + verify(service).createFromApprovedOrder(command); + } + + private static OrderApprovedInboundCommand approvedCommand(String inboundCode, Long carrierId) { + return new OrderApprovedInboundCommand( + 10001L, 77L, "FL-77", inboundCode, 7L, "测试客户", 9L, "义乌仓", + null, null, null, "FBA", "AIR", "空运", carrierId, + carrierId == null ? null : "承运商", null, null, null, null, + null, null, null, null, null, null); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java new file mode 100644 index 000000000..14e42fd17 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java @@ -0,0 +1,59 @@ +package com.baiyue.firstleg.service; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baiyue.firstleg.dto.OrderApprovedInboundCommand; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.mapper.InboundOrderMapper; +import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundOrderCreationServiceTest { + @Mock + private InboundOrderMapper orders; + @Mock + private InboundStatusHistoryMapper histories; + @InjectMocks + private InboundOrderCreationService service; + + @Test + void shouldUseSubmittedInboundCodeAndKeepReplayIdempotentWithoutCarrier() { + OrderApprovedInboundCommand command = approvedCommand("RK-CUSTOM-77"); + InboundOrderEntity existing = new InboundOrderEntity(); + existing.setId(900L); + existing.setTenantId(10001L); + existing.setOrderId(77L); + existing.setInboundCode("RK-CUSTOM-77"); + when(orders.selectOne(any(Wrapper.class))).thenReturn(null, existing); + + long createdId = service.createFromApprovedOrder(command); + long replayedId = service.createFromApprovedOrder(command); + + ArgumentCaptor inboundCaptor = ArgumentCaptor.forClass(InboundOrderEntity.class); + verify(orders, times(1)).insert(inboundCaptor.capture()); + verify(histories, times(1)).insert(any(InboundStatusHistoryEntity.class)); + assertThat(inboundCaptor.getValue().getInboundCode()).isEqualTo("RK-CUSTOM-77"); + assertThat(inboundCaptor.getValue().getCarrierId()).isNull(); + assertThat(createdId).isEqualTo(inboundCaptor.getValue().getId()); + assertThat(replayedId).isEqualTo(900L); + } + + private static OrderApprovedInboundCommand approvedCommand(String inboundCode) { + return new OrderApprovedInboundCommand( + 10001L, 77L, "FL-77", inboundCode, 7L, "测试客户", 9L, "义乌仓", + null, null, null, "FBA", "AIR", "空运", null, null, + null, null, null, null, null, null, null, null, null, null); + } +} 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 6ba498b8f..9a58afa5b 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java @@ -29,7 +29,7 @@ public interface FirstlegInboundClient { } record OrderApprovedInboundCommand( - long tenantId, long orderId, String orderNo, Long customerId, String customerName, + long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName, Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName, LocalDateTime expectedArrivalAt, String businessType, String channelCode, String channelName, Long carrierId, String carrierName, String taxMode, String containerMode, diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index 06ec9cda8..6a7b85119 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -146,7 +146,7 @@ public class PlatformFirstLegOrderController { } @BusinessAudit(event = "ORDER_APPROVE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Approve order / 审核通过") - @Operation(summary = "Approve / 审核通过", description = "Approves a submitted order and records its status history. / 审核通过已提交订单,并记录状态历史。") + @Operation(summary = "Approve / 审核通过", description = "Approves a submitted order without requiring carrier assignment and records its status history. / 审核通过已提交订单,不要求预先指派承运商,并记录状态历史。") @PostMapping("/{id}/approve") @PreAuthorize("hasAuthority('platform:order:approve')") public ApiResponse approve(@AuthenticationPrincipal AuthenticatedUser user, @@ -156,7 +156,7 @@ public class PlatformFirstLegOrderController { } @BusinessAudit(event = "ORDER_ASSIGN_CARRIER", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "指派订单承运商") - @Operation(summary = "指派承运商", description = "为待审核订单选择启用的公共承运商,名称快照由服务端主数据回填。") + @Operation(summary = "指派承运商", description = "可选地为待审核订单选择启用的公共承运商,名称快照由服务端主数据回填;该动作不是审核前置条件。") @PutMapping("/{id}/carrier") @PreAuthorize("hasAuthority('platform:order:approve')") public ApiResponse assignCarrier(@AuthenticationPrincipal AuthenticatedUser user, 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 44d97d8eb..6f0262346 100644 --- a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java +++ b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java @@ -21,6 +21,7 @@ public class OrderEntity extends BaseEntity { private Long id; private Long tenantId; private String orderNo; + private String inboundCode; private Long customerId; private String customerName; private OrderStatus status; 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 c2d9b2eab..e1f3fd187 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -161,6 +161,7 @@ public class OrderRepository { .eq(tenantId != null, OrderEntity::getTenantId, tenantId) .in(OrderEntity::getStatus, statuses) .like(StringUtils.hasText(query.orderNo()), OrderEntity::getOrderNo, query.orderNo()) + .like(StringUtils.hasText(query.inboundCode()), OrderEntity::getInboundCode, query.inboundCode()) .eq(query.customerId() != null, OrderEntity::getCustomerId, query.customerId()) .eq(query.receivingWarehouseId() != null, OrderEntity::getReceivingWarehouseId, query.receivingWarehouseId()) 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 ed44baa9e..90632951c 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -97,6 +97,7 @@ public class OrderApplicationService { throw new BizException("当前订单状态不允许编辑订单资料"); } apply(order, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId); + if (before == OrderStatus.REJECTED) order.setInboundCode(null); if (before == OrderStatus.SUBMITTED) { order.setStatus(OrderStatus.SUBMITTED); } @@ -118,11 +119,7 @@ public class OrderApplicationService { masterDataGuard.validatePersisted(tenantId, order.getCustomerId(), order.getReceivingWarehouseId(), order.getDestinationWarehouseId(), order.getDestinationType()); } - if (target == OrderStatus.APPROVED) { - if (order.getCarrierId() == null || !StringUtils.hasText(order.getCarrierName())) { - throw new BizException(com.baiyue.common.core.api.ResultCode.VALIDATION_FAILED, - "请先选择有效承运商后再审核通过"); - } + if (target == OrderStatus.APPROVED && order.getCarrierId() != null) { var carrier = masterDataGuard.validateCarrier(order.getCarrierId()); order.setCarrierName(carrier.partnerName()); } @@ -130,7 +127,11 @@ public class OrderApplicationService { throw new BizException("Reason is required"); } order.setStatus(target); order.setUpdatedBy(userId); - if (target == OrderStatus.SUBMITTED) order.setSubmittedAt(LocalDateTime.now()); + if (target == OrderStatus.SUBMITTED) { + if (!StringUtils.hasText(order.getInboundCode())) order.setInboundCode("RK-" + order.getId()); + order.setSubmittedAt(LocalDateTime.now()); + } + if (target == OrderStatus.DRAFT) order.setInboundCode(null); if (target == OrderStatus.COMPLETED) order.setCompletedAt(LocalDateTime.now()); if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update"); record(tenantId, id, from, target, target.name(), reason, userId, username); @@ -423,7 +424,8 @@ public class OrderApplicationService { } private FirstlegInboundClient.OrderApprovedInboundCommand inboundCommand(OrderEntity order) { return new FirstlegInboundClient.OrderApprovedInboundCommand( - order.getTenantId(), order.getId(), order.getOrderNo(), order.getCustomerId(), order.getCustomerName(), + 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.getDestinationType() + "_" + order.getTransportMode(), order.getChannelCode(), order.getChannelName(), 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 e45b24dd9..ac48ef282 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -51,8 +51,9 @@ public class UnifiedOrderQueryService { List statuses = "PENDING_REVIEW".equals(query.mainStatus()) ? List.of(OrderStatus.SUBMITTED) : ACTIVE_ORDER_STATUSES; PageResult source = orders.unifiedPage(tenantId, query, statuses); - Map inboundByOrder = summaries( - tenantId, source.records().stream().map(OrderEntity::getId).toList()); + Map inboundByOrder = "PENDING_REVIEW".equals(query.mainStatus()) + ? Map.of() + : summaries(tenantId, source.records().stream().map(OrderEntity::getId).toList()); List rows = source.records().stream() .map(order -> row(order, inboundByOrder.get(order.getId()))).toList(); return new PageResult<>(rows, source.page(), source.size(), source.total(), source.pages()); @@ -64,7 +65,8 @@ public class UnifiedOrderQueryService { OrderDetailResponse orderDetail = new OrderDetailResponse(order, orders.findCargo(tenantId, orderId), orders.findParties(tenantId, orderId), includeInternalNotes ? orders.findNotes(tenantId, orderId) : List.of(), orders.findHistory(tenantId, orderId)); - FirstlegUnifiedOrderClient.Detail inbound = firstlegDetail(tenantId, orderId); + FirstlegUnifiedOrderClient.Detail inbound = order.getStatus() == OrderStatus.SUBMITTED + ? null : firstlegDetail(tenantId, orderId); UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary()); List timeline = new ArrayList<>(); orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent( @@ -121,7 +123,8 @@ public class UnifiedOrderQueryService { order.getStatus() == OrderStatus.SUBMITTED ? "APPROVE_ORDER" : null, order.getStatus(), order.getCustomerId(), order.getCustomerName(), order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), - order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null, null, + order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null, + order.getInboundCode(), order.getExpectedShipDate(), null, null, null, null, null, null, null, null, order.getCreatedAt(), order.getUpdatedAt()); } @@ -213,7 +216,7 @@ public class UnifiedOrderQueryService { private boolean requiresFirstlegQuery(UnifiedOrderQuery query) { return (StringUtils.hasText(query.mainStatus()) && !"PENDING_REVIEW".equals(query.mainStatus())) - || StringUtils.hasText(query.inboundCode()) || query.expectedArrivalDate() != null + || query.expectedArrivalDate() != null || StringUtils.hasText(query.feeReviewStatus()) || StringUtils.hasText(query.containerNo()) || StringUtils.hasText(query.cabinetNo()) || query.sailingDate() != null || query.loaded() != null || StringUtils.hasText(query.measurementReviewStatus()); diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index 47368a380..2f8bae6d5 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', order_no VARCHAR(32) NOT NULL COMMENT '租户内唯一订单号', + inbound_code VARCHAR(40) NULL COMMENT '客户提交后生成的入仓码,草稿为空', customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID', customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照', status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态', @@ -47,6 +48,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_order_tenant_no (tenant_id, order_no), + UNIQUE KEY uk_order_tenant_inbound_code (tenant_id, inbound_code), KEY idx_order_tenant_status_time (tenant_id, status, created_at), KEY idx_order_tenant_customer (tenant_id, customer_id), KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index c952b3ae2..f3182588a 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -11,7 +11,8 @@ paths: summary: 平台统一订单列表 description: >- 统一返回七态产品主状态、下一步动作及测量/预装/装柜/派送摘要,需要 platform:order:list。 - 有入库单时同时返回 inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion + 草稿不生成 inboundCode,客户提交进入待审核后即返回固定 inboundCode;有入库单时同时返回 + inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion 才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。 tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询, 指定其他主状态时由 Firstleg 精确分页。旧 /orders/first-leg 接口保持兼容。 @@ -45,7 +46,7 @@ paths: /api/platform/v1/orders/{orderId}: get: summary: 平台统一订单详情 - description: 合并 Order 与 Firstleg 时间线并按发生时间升序返回;平台可见内部备注。 + description: 合并 Order 与 Firstleg 时间线并按发生时间升序返回;待审核订单直接返回 Order 在提交时生成的 inboundCode;平台可见内部备注。 parameters: - $ref: '#/components/parameters/UnifiedOrderId' - $ref: '#/components/parameters/UnifiedTenantId' @@ -222,7 +223,7 @@ paths: /api/platform/v1/orders/first-leg/{id}/carrier: put: summary: 为待审核订单指派承运商 - description: 只接收承运商 ID;服务端从公共往来单位主数据校验 CARRIER 类型并写入可信名称快照。审核通过前必须完成指派。 + description: 可选的独立指派动作;服务端从公共往来单位主数据校验 CARRIER 类型并写入可信名称快照,不作为审核通过的前置条件。 parameters: - $ref: '#/components/parameters/OrderId' - $ref: '#/components/parameters/TenantId' @@ -263,7 +264,7 @@ components: type: string enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED] UnifiedInboundCode: - {name: inboundCode, in: query, required: false, schema: {type: string, maxLength: 64}} + {name: inboundCode, in: query, required: false, description: 客户提交后生成的入仓码,待审核订单同样可查询, schema: {type: string, maxLength: 40}} UnifiedInternalOrderStatus: name: internalOrderStatus in: query diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 9a24965f5..86d0cd632 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -54,7 +54,8 @@ class OrderApplicationServiceStateTest { assertThat(orderId).isNotNull(); verify(eligibility, never()).requireApproved(anyLong()); - verify(repository).insertOrder(argThat(order -> order.getStatus() == OrderStatus.DRAFT)); + verify(repository).insertOrder(argThat(order -> order.getStatus() == OrderStatus.DRAFT + && order.getInboundCode() == null)); } @Test @@ -120,17 +121,20 @@ class OrderApplicationServiceStateTest { } @Test - void approvalRequiresCarrierAssignment() { + void approvalDoesNotRequireCarrierAssignmentAndPublishesInboundCode() { OrderRepository repository = mock(OrderRepository.class); OrderEntity order = persistedOrder(OrderStatus.SUBMITTED); + order.setOrderNo("FL1"); + order.setInboundCode("RK-1"); when(repository.findOwned(10001L, 1L)).thenReturn(order); + when(repository.saveOrder(10001L, order)).thenReturn(1); - assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)) - .transit(10001L, 2L, "u", 1L, OrderStatus.APPROVED, null)) - .isInstanceOf(BizException.class) - .hasMessageContaining("请先选择有效承运商"); + service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)) + .transit(10001L, 2L, "u", 1L, OrderStatus.APPROVED, null); - verify(repository, never()).saveOrder(anyLong(), any()); + assertThat(order.getStatus()).isEqualTo(OrderStatus.APPROVED); + verify(repository).insertOutbox(argThat(event -> event.getPayloadJson().contains("\"inboundCode\":\"RK-1\"") + && event.getPayloadJson().contains("\"carrierId\":null"))); } @Test @@ -153,6 +157,25 @@ class OrderApplicationServiceStateTest { verify(masterData).validateCarrier(9L); } + @Test + void submittingDraftGeneratesAndPersistsInboundCode() { + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS"); + when(repository.findOwned(20002L, 1L)).thenReturn(order); + when(repository.findCargo(20002L, 1L)).thenReturn(List.of(new OrderCargoEntity())); + when(repository.findParties(20002L, 1L)).thenReturn(List.of()); + when(repository.saveOrder(20002L, order)).thenReturn(1); + + service(repository, mock(OrderEligibilityGuard.class), masterData) + .transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED); + assertThat(order.getInboundCode()).isEqualTo("RK-1"); + assertThat(order.getSubmittedAt()).isNotNull(); + verify(repository).saveOrder(20002L, order); + } + @Test void merchantDraftEditorStillCannotEditSubmittedOrder() { OrderRepository repository = mock(OrderRepository.class); 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 246bcec01..59df0cb15 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -38,6 +38,7 @@ class UnifiedOrderQueryServiceTest { void pendingReviewIsPagedFromOrderAndMappedToApproveAction() { UnifiedOrderQuery query = query("PENDING_REVIEW"); OrderEntity order = order(OrderStatus.SUBMITTED); + order.setInboundCode("RK-77"); when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED)))) .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); when(firstleg.summaries(eq("internal-token"), any())) @@ -48,7 +49,58 @@ class UnifiedOrderQueryServiceTest { assertThat(result.records()).singleElement().satisfies(row -> { assertThat(row.mainStatus()).isEqualTo("PENDING_REVIEW"); assertThat(row.nextAction()).isEqualTo("APPROVE_ORDER"); + assertThat(row.inboundCode()).isEqualTo("RK-77"); + assertThat(row.inboundId()).isNull(); }); + verify(firstleg, never()).summaries(any(), any()); + } + + @Test + void pendingReviewInboundCodeFilterStaysInOrderQuery() { + UnifiedOrderQuery query = new UnifiedOrderQuery( + null, null, "PENDING_REVIEW", null, null, null, "RK-77", null, + null, null, null, null, null, null, null, null, null, null, + null, null, 1, 20); + when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED)))) + .thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + + service.page(10001L, query); + + verify(orders).unifiedPage(10001L, query, List.of(OrderStatus.SUBMITTED)); + verify(firstleg, never()).page(any(), any()); + verify(firstleg, never()).summaries(any(), any()); + } + + @Test + void allStatusInboundCodeFilterIncludesPendingOrdersFromOrderQuery() { + UnifiedOrderQuery query = new UnifiedOrderQuery( + null, null, null, null, null, null, "RK-77", null, + null, null, null, null, null, null, null, null, null, null, + null, null, 1, 20); + when(orders.unifiedPage(eq(10001L), eq(query), any())) + .thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + + service.page(10001L, query); + + verify(orders).unifiedPage(eq(10001L), eq(query), any()); + verify(firstleg, never()).page(any(), any()); + } + + @Test + void pendingReviewDetailUsesOrderInboundCodeWithoutCallingFirstleg() { + OrderEntity order = order(OrderStatus.SUBMITTED); + order.setInboundCode("RK-77"); + when(orders.findOwned(10001L, 77L)).thenReturn(order); + when(orders.findCargo(10001L, 77L)).thenReturn(List.of()); + when(orders.findParties(10001L, 77L)).thenReturn(List.of()); + when(orders.findNotes(10001L, 77L)).thenReturn(List.of()); + when(orders.findHistory(10001L, 77L)).thenReturn(List.of()); + + var result = service.detail(10001L, 77L, true); + + assertThat(result.summary().inboundCode()).isEqualTo("RK-77"); + assertThat(result.order().order().getInboundCode()).isEqualTo("RK-77"); + verify(firstleg, never()).detail(any(), any()); } @Test