This commit is contained in:
2026-08-07 10:45:09 +08:00
parent 897f91af20
commit 830575cc92
17 changed files with 295 additions and 28 deletions
@@ -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',
@@ -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));
@@ -7,6 +7,7 @@ public record OrderApprovedInboundCommand(
long tenantId,
long orderId,
String orderNo,
String inboundCode,
Long customerId,
String customerName,
Long warehouseId,
@@ -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");
@@ -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:
@@ -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);
}
}
@@ -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<InboundOrderEntity> 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);
}
}
@@ -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,
@@ -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<Void> 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<Void> assignCarrier(@AuthenticationPrincipal AuthenticatedUser user,
@@ -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;
@@ -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())
@@ -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(),
@@ -51,8 +51,9 @@ public class UnifiedOrderQueryService {
List<OrderStatus> statuses = "PENDING_REVIEW".equals(query.mainStatus())
? List.of(OrderStatus.SUBMITTED) : ACTIVE_ORDER_STATUSES;
PageResult<OrderEntity> source = orders.unifiedPage(tenantId, query, statuses);
Map<Long, FirstlegUnifiedOrderClient.Summary> inboundByOrder = summaries(
tenantId, source.records().stream().map(OrderEntity::getId).toList());
Map<Long, FirstlegUnifiedOrderClient.Summary> inboundByOrder = "PENDING_REVIEW".equals(query.mainStatus())
? Map.of()
: summaries(tenantId, source.records().stream().map(OrderEntity::getId).toList());
List<UnifiedOrderResponse.Row> 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<UnifiedOrderResponse.TimelineEvent> 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());
+2
View File
@@ -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)
@@ -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
@@ -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);
@@ -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