Merge branch 'test' into test-02huogui

# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java
#	firstleg/src/main/resources/openapi/firstleg-v1.yaml
This commit is contained in:
2026-08-07 11:11:43 +08:00
55 changed files with 3173 additions and 94 deletions
@@ -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,
@@ -155,6 +155,19 @@ public class PlatformFirstLegOrderController {
return transit(user, tenantId, id, OrderStatus.APPROVED, null);
}
@BusinessAudit(event = "ORDER_ASSIGN_CARRIER", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "指派订单承运商")
@Operation(summary = "指派承运商", description = "可选地为待审核订单选择启用的公共承运商,名称快照由服务端主数据回填;该动作不是审核前置条件。")
@PutMapping("/{id}/carrier")
@PreAuthorize("hasAuthority('platform:order:approve')")
public ApiResponse<Void> assignCarrier(@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId,
@Parameter(description = "订单 ID", required = true) @PathVariable long id,
@Valid @RequestBody OrderRequests.CarrierRequest request) {
long scopedTenant = OrderScopeGuard.platform(user, tenantId);
service.assignCarrier(scopedTenant, user.userId(), id, request);
return ApiResponse.success(null);
}
@BusinessAudit(event = "ORDER_REJECT", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Reject order / 驳回订单")
@Operation(summary = "Reject / 驳回", description = "Rejects a submitted order with a required reason. / 使用必填原因驳回已提交订单。")
@PostMapping("/{id}/reject")
@@ -2,9 +2,12 @@ package com.baiyue.order.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.audit.BusinessAudit;
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.service.OrderApplicationService;
import com.baiyue.order.service.OrderScopeGuard;
import com.baiyue.order.service.UnifiedOrderQueryService;
import io.swagger.v3.oas.annotations.Operation;
@@ -17,6 +20,8 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
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;
/** Platform product-level order query. Legacy first-leg paths remain available. */
@@ -25,6 +30,7 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/platform/v1/orders")
public class PlatformUnifiedOrderController {
@Resource private UnifiedOrderQueryService service;
@Resource private OrderApplicationService orderService;
@GetMapping
@PreAuthorize("hasAuthority('platform:order:list')")
@@ -47,4 +53,17 @@ public class PlatformUnifiedOrderController {
@PathVariable long orderId) {
return ApiResponse.success(service.detail(OrderScopeGuard.platform(user, tenantId), orderId, true));
}
@PostMapping("/{orderId}/internal-notes")
@BusinessAudit(event = "ORDER_INTERNAL_NOTE", resourceType = "FIRST_LEG_ORDER", resourceId = "#orderId", description = "Add internal note / 添加内部备注")
@PreAuthorize("hasAuthority('platform:order:note')")
@Operation(summary = "添加订单内部备注", description = "添加仅平台内部可见的订单备注。")
public ApiResponse<Long> addInternalNote(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long orderId,
@Valid @RequestBody OrderRequests.NoteRequest request) {
long scopedTenant = OrderScopeGuard.platform(user, tenantId);
return ApiResponse.success(orderService.addNote(scopedTenant, user.userId(), orderId, request.content()));
}
}
@@ -3,6 +3,7 @@ package com.baiyue.order.dto;
import com.baiyue.order.entity.OrderStatus;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
@@ -55,8 +56,10 @@ public final class OrderRequests {
@NotBlank @Size(max = 128) String receivingWarehouseName,
@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 128) String channelName,
@NotNull Long carrierId,
@NotBlank @Size(max = 128) String carrierName,
@Schema(description = "Carrier ID snapshot; optional until the order channel resolves a carrier / 承运商 ID 快照,渠道明确承运商后可传")
@Min(1) Long carrierId,
@Schema(description = "Carrier name snapshot; optional until the order channel resolves a carrier / 承运商名称快照,渠道明确承运商后可传", maxLength = 128)
@Size(max = 128) String carrierName,
@NotBlank @Pattern(regexp = "BUY_DECLARATION|TAX_REFUND") String taxMode,
@Pattern(regexp = "FULL_CONTAINER|LCL") String containerMode,
@NotNull Boolean customsInspection,
@@ -64,6 +67,17 @@ public final class OrderRequests {
@Size(max = 32) String amazonWarehouseCode,
@Size(max = 64) String shipmentId,
@Size(max = 64) String referenceId
) {
@AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空")
public boolean isCarrierSnapshotComplete() {
return (carrierId == null) == (carrierName == null || carrierName.isBlank());
}
}
@Schema(name = "FirstLegOrderCarrierRequest", description = "Platform carrier assignment / 平台指派承运商")
public record CarrierRequest(
@Schema(description = "Active public carrier ID / 启用的公共承运商 ID", example = "193000000000300001", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull @Min(1) Long carrierId
) {
}
@@ -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())
@@ -73,6 +73,19 @@ public class OrderApplicationService {
updateOrderData(tenantId, userId, id, request, true);
}
@Transactional
public void assignCarrier(long tenantId, long userId, long id, OrderRequests.CarrierRequest request) {
OrderEntity order = require(tenantId, id);
if (order.getStatus() != OrderStatus.SUBMITTED) {
throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "仅待审核订单可以指派承运商");
}
var carrier = masterDataGuard.validateCarrier(request.carrierId());
order.setCarrierId(carrier.id());
order.setCarrierName(carrier.partnerName());
order.setUpdatedBy(userId);
if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update");
}
private void updateOrderData(long tenantId, long userId, long id, OrderRequests.SaveRequest request,
boolean allowSubmitted) {
OrderEntity order = require(tenantId, id);
@@ -84,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);
}
@@ -105,11 +119,19 @@ public class OrderApplicationService {
masterDataGuard.validatePersisted(tenantId, order.getCustomerId(), order.getReceivingWarehouseId(),
order.getDestinationWarehouseId(), order.getDestinationType());
}
if (target == OrderStatus.APPROVED && order.getCarrierId() != null) {
var carrier = masterDataGuard.validateCarrier(order.getCarrierId());
order.setCarrierName(carrier.partnerName());
}
if ((target == OrderStatus.REJECTED || target == OrderStatus.CANCELLED) && !StringUtils.hasText(reason)) {
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);
@@ -327,7 +349,9 @@ public class OrderApplicationService {
order.setReceivingTime(formatReceivingTime(snapshots.receiving()));
order.setChannelCode(request.channelCode());
order.setChannelName(request.channelName());
order.setCarrierId(request.carrierId()); order.setCarrierName(request.carrierName());
order.setCarrierId(request.carrierId());
order.setCarrierName(request.carrierId() == null || !StringUtils.hasText(request.carrierName())
? null : request.carrierName().trim());
order.setTaxMode(request.taxMode());
order.setContainerMode(request.containerMode()); order.setCustomsInspection(request.customsInspection());
order.setDestinationType(request.destinationType());
@@ -400,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(),
@@ -78,6 +78,37 @@ public class OrderMasterDataGuard {
throw downstreamFailure(exception);
}
}
public PartnerClient.Partner validateCarrier(long id) {
try {
ApiResponse<PartnerClient.Partner> response;
try {
response = partners.active(token, publicTenantId, id);
} catch (FeignException.NotFound exception) {
throw new BizException(ResultCode.NOT_FOUND, "承运商不存在或不可用");
}
requireSuccessful(response, "承运商");
if (response.data() == null) throw unavailable("承运商");
var value = response.data();
if (!Long.valueOf(id).equals(value.id())) throw unavailable("承运商");
if (!Long.valueOf(publicTenantId).equals(value.tenantId()))
throw new BizException(ResultCode.FORBIDDEN, "承运商不属于平台公共主数据");
if (!"ACTIVE".equals(value.status()))
throw new BizException(ResultCode.CONFLICT, "承运商已停用");
boolean hasCarrierType = value.types() != null && value.types().contains("CARRIER");
boolean legacyCarrierType = (value.types() == null || value.types().isEmpty())
&& "CARRIER".equals(value.partnerType());
if (!hasCarrierType && !legacyCarrierType)
throw new BizException(ResultCode.VALIDATION_FAILED, "所选往来单位不具备承运商类型");
if (!StringUtils.hasText(value.partnerName())) throw unavailable("承运商");
return value;
} catch (FeignException exception) {
throw downstreamFailure(exception);
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw downstreamFailure(exception);
}
}
private PartnerClient.Partner loadPartner(long id) {
ApiResponse<PartnerClient.Partner> response;
try {
@@ -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)
+43 -4
View File
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: 柏悦头程订单 API
description: 业务成功和失败均使用 HTTP 200;仅响应体 code 为字符串 "200" 时表示成功,其他 code 表示失败。
version: 1.3.0
version: 1.4.0
security:
- bearerAuth: []
paths:
@@ -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,13 +46,32 @@ 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'
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要平台身份和 platform:order:list 权限}
/api/platform/v1/orders/{orderId}/internal-notes:
post:
summary: 添加订单内部备注
description: 添加仅平台内部可见的订单备注,需要 platform:order:note 权限。
parameters:
- $ref: '#/components/parameters/UnifiedOrderId'
- $ref: '#/components/parameters/UnifiedTenantId'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [content]
properties:
content: {type: string, minLength: 1, maxLength: 2000}
responses:
'200': {description: 备注添加成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:note 权限}
/api/merchant/v1/orders:
get:
summary: 客户统一订单列表
@@ -200,6 +220,25 @@ paths:
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:edit 权限}
/api/platform/v1/orders/first-leg/{id}/carrier:
put:
summary: 为待审核订单指派承运商
description: 可选的独立指派动作;服务端从公共往来单位主数据校验 CARRIER 类型并写入可信名称快照,不作为审核通过的前置条件。
parameters:
- $ref: '#/components/parameters/OrderId'
- $ref: '#/components/parameters/TenantId'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [carrierId]
properties:
carrierId: {type: string, pattern: '^[1-9][0-9]*$'}
responses:
'200': {description: 指派成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:approve 权限}
components:
securitySchemes:
bearerAuth:
@@ -225,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