This commit is contained in:
2026-08-06 18:01:59 +08:00
parent 584f1519d1
commit 6e9739826c
24 changed files with 549 additions and 62 deletions
@@ -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")
@@ -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
) {
}
@@ -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);
@@ -105,6 +118,14 @@ 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,
"请先选择有效承运商后再审核通过");
}
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");
}
@@ -327,7 +348,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());
@@ -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 {
+20 -1
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:
@@ -200,6 +200,25 @@ paths:
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:edit 权限}
/api/platform/v1/orders/first-leg/{id}/carrier:
put:
summary: 为待审核订单指派承运商
description: 只接收承运商 ID;服务端从公共往来单位主数据校验 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:
@@ -41,6 +41,8 @@ class OrderControllerPermissionTest {
AuthenticatedUser.class, long.class, long.class, OrderRequests.SaveRequest.class);
assertAuthority(PlatformFirstLegOrderController.class, "submit", "hasAuthority('platform:order:submit')",
AuthenticatedUser.class, long.class, long.class, String.class);
assertAuthority(PlatformFirstLegOrderController.class, "assignCarrier", "hasAuthority('platform:order:approve')",
AuthenticatedUser.class, long.class, long.class, OrderRequests.CarrierRequest.class);
}
@Test
@@ -0,0 +1,41 @@
package com.baiyue.order.dto;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class OrderRequestsValidationTest {
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Test
void carrierSnapshotIsOptionalWhenCustomerCreatesAnOrderDraft() {
assertThat(validator.validate(request(null, null))).isEmpty();
}
@Test
void carrierSnapshotMustBeBothPresentOrBothAbsent() {
assertThat(validator.validate(request(9L, null)))
.anyMatch(violation -> violation.getMessage().contains("必须同时填写或同时留空"));
assertThat(validator.validate(request(null, "船司")))
.anyMatch(violation -> violation.getMessage().contains("必须同时填写或同时留空"));
assertThat(validator.validate(request(9L, "船司"))).isEmpty();
}
private OrderRequests.SaveRequest request(Long carrierId, String carrierName) {
var cargo = new OrderRequests.Cargo(
"货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE,
null, null, null, null, null, false, null, null, null, null,
null, null, null, null, null, null, null, null, null);
var request = new OrderRequests.SaveRequest(
null, "当前企业", "SEA", "FOB", "US", 193000000000200003L, "洛杉矶仓",
null, null, null, null, List.of(cargo), "SELF_EXPRESS", 193000000000200001L,
"义乌仓", "SEA-US-GENERAL", "美线普货专线", carrierId, carrierName, "BUY_DECLARATION",
"FULL_CONTAINER", true, "OVERSEAS", null, null, "123456789");
return request;
}
}
@@ -101,6 +101,58 @@ class OrderApplicationServiceStateTest {
&& "UPDATE".equals(history.getAction())));
}
@Test
void platformAssignsTrustedCarrierSnapshotToSubmittedOrder() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderEntity order = persistedOrder(OrderStatus.SUBMITTED);
when(repository.findOwned(10001L, 1L)).thenReturn(order);
when(repository.saveOrder(10001L, order)).thenReturn(1);
when(masterData.validateCarrier(9L)).thenReturn(
new PartnerClient.Partner(9L, 0L, "服务端船司名", "ACTIVE", "CARRIER", List.of("CARRIER")));
service(repository, mock(OrderEligibilityGuard.class), masterData)
.assignCarrier(10001L, 2L, 1L, new OrderRequests.CarrierRequest(9L));
assertThat(order.getCarrierId()).isEqualTo(9L);
assertThat(order.getCarrierName()).isEqualTo("服务端船司名");
verify(repository).saveOrder(10001L, order);
}
@Test
void approvalRequiresCarrierAssignment() {
OrderRepository repository = mock(OrderRepository.class);
OrderEntity order = persistedOrder(OrderStatus.SUBMITTED);
when(repository.findOwned(10001L, 1L)).thenReturn(order);
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
.transit(10001L, 2L, "u", 1L, OrderStatus.APPROVED, null))
.isInstanceOf(BizException.class)
.hasMessageContaining("请先选择有效承运商");
verify(repository, never()).saveOrder(anyLong(), any());
}
@Test
void approvalRevalidatesCarrierAndRefreshesItsName() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderEntity order = persistedOrder(OrderStatus.SUBMITTED);
order.setCarrierId(9L);
order.setCarrierName("旧名称");
when(repository.findOwned(10001L, 1L)).thenReturn(order);
when(repository.saveOrder(10001L, order)).thenReturn(1);
when(masterData.validateCarrier(9L)).thenReturn(
new PartnerClient.Partner(9L, 0L, "最新船司名", "ACTIVE", "CARRIER", List.of("CARRIER")));
service(repository, mock(OrderEligibilityGuard.class), masterData)
.transit(10001L, 2L, "u", 1L, OrderStatus.APPROVED, null);
assertThat(order.getStatus()).isEqualTo(OrderStatus.APPROVED);
assertThat(order.getCarrierName()).isEqualTo("最新船司名");
verify(masterData).validateCarrier(9L);
}
@Test
void merchantDraftEditorStillCannotEditSubmittedOrder() {
OrderRepository repository = mock(OrderRepository.class);
@@ -297,4 +349,12 @@ class OrderApplicationServiceStateTest {
order.setDestinationType(destinationType);
return order;
}
private OrderEntity persistedOrder(OrderStatus status) {
OrderEntity order = new OrderEntity();
order.setId(1L);
order.setTenantId(10001L);
order.setStatus(status);
return order;
}
}
@@ -45,6 +45,31 @@ class OrderMasterDataGuardTest {
assertThat(result.receiving().warehouse().warehouseName()).isEqualTo("深圳仓");
}
@Test
void carrierValidationReturnsTrustedPublicCarrierSnapshot() {
var carrier = new PartnerClient.Partner(9L, PUBLIC_TENANT_ID, "服务端船司名", "ACTIVE", "CARRIER", List.of("SUPPLIER", "CARRIER"));
when(partners.active("token", PUBLIC_TENANT_ID, 9L)).thenReturn(ApiResponse.success(carrier));
assertThat(guard.validateCarrier(9L).partnerName()).isEqualTo("服务端船司名");
}
@Test
void carrierValidationSupportsLegacySingleTypeSnapshot() {
var carrier = new PartnerClient.Partner(9L, PUBLIC_TENANT_ID, "旧数据船司", "ACTIVE", "CARRIER", null);
when(partners.active("token", PUBLIC_TENANT_ID, 9L)).thenReturn(ApiResponse.success(carrier));
assertThat(guard.validateCarrier(9L).partnerName()).isEqualTo("旧数据船司");
}
@Test
void partnerWithoutCarrierTypeCannotBeAssignedToOrder() {
var supplier = new PartnerClient.Partner(9L, PUBLIC_TENANT_ID, "普通供应商", "ACTIVE", "SUPPLIER", List.of("SUPPLIER"));
when(partners.active("token", PUBLIC_TENANT_ID, 9L)).thenReturn(ApiResponse.success(supplier));
assertThatThrownBy(() -> guard.validateCarrier(9L))
.isInstanceOf(BizException.class)
.hasMessageContaining("不具备承运商类型")
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.VALIDATION_FAILED);
}
@Test
void inactiveOrCrossTenantMasterDataIsRejected() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(null));
assertThatThrownBy(() -> guard.validate(10002,request(7L,8L)))