二期内容提交

This commit is contained in:
2026-08-04 17:18:07 +08:00
parent db72bdf884
commit b7d36d4766
30 changed files with 537 additions and 19 deletions
+1
View File
@@ -11,6 +11,7 @@ firstleg:
allow-rejected-measurement: false
default-pallet-capacity-cbm: ${DEFAULT_PALLET_CAPACITY_CBM:1.200000}
reservation-ttl: ${PALLET_RESERVATION_TTL:30m}
warehouse-resource-tenant-id: ${WAREHOUSE_RESOURCE_TENANT_ID:0}
measurement-openapi:
enabled: ${MEASUREMENT_OPENAPI_ENABLED:false}
max-clock-skew: ${MEASUREMENT_OPENAPI_MAX_CLOCK_SKEW:5m}
@@ -315,7 +315,9 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result (
adjusted_at DATETIME(3) NULL COMMENT '修正时间',
confirmed_by BIGINT UNSIGNED NULL COMMENT '确认人ID',
confirmed_at DATETIME(3) NULL COMMENT '确认时间',
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
@@ -18,4 +18,6 @@ public class InboundProperties {
private boolean allowRejectedMeasurement = false;
private BigDecimal defaultPalletCapacityCbm = new BigDecimal("1.200000");
private Duration reservationTtl = Duration.ofMinutes(30);
/** 共享收货仓资源所属租户;0 表示与入库业务租户一致。 */
private long warehouseResourceTenantId = 0L;
}
@@ -0,0 +1,44 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
import com.baiyue.firstleg.service.InboundOrderCreationService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/** 接收订单服务审核通过事件,创建入库单。 */
@RestController
@RequestMapping("/internal/firstleg/v1/inbounds")
public class InternalInboundOrderController {
private final InboundOrderCreationService service;
private final byte[] expectedToken;
public InternalInboundOrderController(InboundOrderCreationService service,
@Value("${baiyue.security.internal-service-token}") String token) {
this.service = service;
this.expectedToken = token.getBytes(StandardCharsets.UTF_8);
}
@PostMapping("/order-approved")
public ApiResponse<Long> createFromApprovedOrder(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody OrderApprovedInboundCommand command) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
}
if (command.tenantId() <= 0 || command.orderId() <= 0 || command.warehouseId() == null
|| command.warehouseId() <= 0 || command.customerName() == null || command.customerName().isBlank()) {
throw new BizException(ResultCode.BAD_REQUEST, "订单入库快照不完整");
}
return ApiResponse.success(service.createFromApprovedOrder(command));
}
}
@@ -11,6 +11,7 @@ public record InboundOrderSummary(
Long id, Long orderId, String orderNo, String inboundCode,
String processStatus, String orderStatusSnapshot, String measureReviewStatus,
String feeStatus, String feeReviewStatus, String notificationStatus,
Integer version,
LocalDateTime expectedArrivalAt,
Long customerId, String customerName, Long companyId, String companyName,
Long warehouseId, String warehouseName,
@@ -35,6 +36,7 @@ public record InboundOrderSummary(
order.getId(), order.getOrderId(), order.getOrderNo(), order.getInboundCode(),
order.getProcessStatus(), order.getOrderStatusSnapshot(), order.getMeasureReviewStatus(),
order.getFeeStatus(), order.getFeeReviewStatus(), order.getNotificationStatus(),
order.getVersion(),
order.getExpectedArrivalAt(), order.getCustomerId(), order.getCustomerName(),
order.getCompanyId(), order.getCompanyName(), order.getWarehouseId(), order.getWarehouseName(),
order.getDeliveryWarehouseId(), order.getDeliveryWarehouseName(), order.getBusinessType(),
@@ -0,0 +1,33 @@
package com.baiyue.firstleg.dto;
import java.time.LocalDateTime;
/** 订单审核通过后创建入库单的内部服务契约。 */
public record OrderApprovedInboundCommand(
long tenantId,
long orderId,
String orderNo,
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,
Boolean customsInspection,
String destinationType,
Long destinationWarehouseId,
String destinationCode,
String destinationName,
String publicRemark,
String expressCompanyName,
String expressTrackingNo
) {
}
@@ -40,6 +40,8 @@ public class InboundOrderEntity extends BaseEntity {
private String businessType;
private String channelCode;
private String channelName;
private Long carrierId;
private String carrierName;
private String taxMode;
private String containerMode;
private Boolean customsInspection;
@@ -9,6 +9,9 @@ import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@Repository
public class InboundPalletAllocationRepository {
@@ -21,6 +24,11 @@ public class InboundPalletAllocationRepository {
@Transactional
public void replace(long tenantId, long inboundId, long warehouseId, BigDecimal capacity,
BatchResult result) {
Map<Long, String> reservationNos = mapper.selectList(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId))
.stream().collect(Collectors.toMap(InboundPalletAllocationEntity::getPalletId,
InboundPalletAllocationEntity::getReservationNo, (left, right) -> left));
mapper.delete(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId));
@@ -37,7 +45,9 @@ public class InboundPalletAllocationRepository {
value.setAllocatedVolumeCbm(source.allocatedVolumeCbm());
value.setLocationId(source.locationId());
value.setLocationCode(source.locationCode());
value.setReservationNo(source.reservationNo());
// 仓储确认响应只返回 reservationId;沿用预占阶段的预占单号快照。
value.setReservationNo(source.reservationNo() != null ? source.reservationNo()
: reservationNos.get(source.palletId()));
value.setAllocationStatus(normalize(source.state()));
value.setCreatedAt(now);
value.setUpdatedAt(now);
@@ -0,0 +1,89 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
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.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/** 将已审核的订单快照幂等转换为待测量入库单。 */
@Service
public class InboundOrderCreationService {
private final InboundOrderMapper orders;
private final InboundStatusHistoryMapper histories;
public InboundOrderCreationService(InboundOrderMapper orders, InboundStatusHistoryMapper histories) {
this.orders = orders;
this.histories = histories;
}
@Transactional
public long createFromApprovedOrder(OrderApprovedInboundCommand command) {
InboundOrderEntity existing = orders.selectOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, command.tenantId())
.eq(InboundOrderEntity::getOrderId, command.orderId()));
if (existing != null) {
return existing.getId();
}
LocalDateTime now = LocalDateTime.now();
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(IdWorker.getId());
inbound.setTenantId(command.tenantId());
inbound.setOrderId(command.orderId());
inbound.setOrderNo(command.orderNo());
inbound.setInboundCode("RK-" + command.orderId());
inbound.setProcessStatus("WAIT_MEASURE");
inbound.setOrderStatusSnapshot("APPROVED");
inbound.setMeasureReviewStatus("NOT_SUBMITTED");
inbound.setFeeStatus("UNPRICED");
inbound.setFeeReviewStatus("NOT_SUBMITTED");
inbound.setNotificationStatus("NOT_CREATED");
inbound.setExpectedArrivalAt(command.expectedArrivalAt());
inbound.setCustomerId(command.customerId());
inbound.setCustomerName(command.customerName());
inbound.setWarehouseId(command.warehouseId());
inbound.setWarehouseName(command.warehouseName());
inbound.setDeliveryWarehouseId(command.deliveryWarehouseId());
inbound.setDeliveryWarehouseName(command.deliveryWarehouseName());
inbound.setBusinessType(command.businessType());
inbound.setChannelCode(command.channelCode());
inbound.setChannelName(command.channelName());
inbound.setCarrierId(command.carrierId());
inbound.setCarrierName(command.carrierName());
inbound.setTaxMode(command.taxMode());
inbound.setContainerMode(command.containerMode());
inbound.setCustomsInspection(command.customsInspection());
inbound.setDestinationType(command.destinationType());
inbound.setDestinationWarehouseId(command.destinationWarehouseId());
inbound.setDestinationCode(command.destinationCode());
inbound.setDestinationName(command.destinationName());
inbound.setExpressCompanyName(command.expressCompanyName());
inbound.setExpressTrackingNo(command.expressTrackingNo());
inbound.setPublicRemark(command.publicRemark());
inbound.setCreatedBy(0L);
inbound.setUpdatedBy(0L);
inbound.setCreatedAt(now);
inbound.setUpdatedAt(now);
inbound.setDeleted(false);
inbound.setVersion(0);
orders.insert(inbound);
InboundStatusHistoryEntity history = new InboundStatusHistoryEntity();
history.setId(IdWorker.getId());
history.setTenantId(command.tenantId());
history.setInboundId(inbound.getId());
history.setToStatus("WAIT_MEASURE");
history.setAction("ORDER_APPROVED");
history.setOperatorId(0L);
history.setOperatorName("订单服务");
history.setOccurredAt(now);
histories.insert(history);
return inbound.getId();
}
}
@@ -39,10 +39,12 @@ public class MeasurementLocationOccupationService {
int measurementVersion) {
InboundOrderEntity order = requireOrder(tenantId, inboundId);
long warehouseId = requireWarehouse(order);
long resourceTenantId = properties.getWarehouseResourceTenantId() > 0
? properties.getWarehouseResourceTenantId() : tenantId;
BigDecimal volume = requireVolume(order);
String key = "measure-" + inboundId + "-" + measurementId + "-" + measurementVersion;
WarehousePalletLocationClient.BatchResult reserved = requireSuccess(call(() -> warehouse.reserve(
internalToken, new WarehousePalletLocationClient.ReserveRequest(tenantId, warehouseId,
internalToken, new WarehousePalletLocationClient.ReserveRequest(resourceTenantId, warehouseId,
SOURCE_SERVICE, inboundId, order.getInboundCode(), key, operatorId, volume,
properties.getDefaultPalletCapacityCbm(), "STANDARD",
LocalDateTime.now().plus(properties.getReservationTtl()), true))),
@@ -51,7 +53,7 @@ public class MeasurementLocationOccupationService {
properties.getDefaultPalletCapacityCbm(), reserved);
try {
WarehousePalletLocationClient.BatchResult occupied = requireSuccess(call(() -> warehouse.confirm(
internalToken, new WarehousePalletLocationClient.ConfirmRequest(tenantId, warehouseId,
internalToken, new WarehousePalletLocationClient.ConfirmRequest(resourceTenantId, warehouseId,
SOURCE_SERVICE, inboundId, operatorId, volume,
properties.getDefaultPalletCapacityCbm()))), "测量完成后库位占用失败");
allocations.replace(tenantId, inboundId, warehouseId,
@@ -59,7 +61,7 @@ public class MeasurementLocationOccupationService {
} catch (RuntimeException failure) {
try {
warehouse.release(internalToken, new WarehousePalletLocationClient.ReleaseRequest(
tenantId, SOURCE_SERVICE, inboundId, operatorId, "测量完成库位占用失败"));
resourceTenantId, SOURCE_SERVICE, inboundId, operatorId, "测量完成库位占用失败"));
} catch (RuntimeException releaseFailure) {
failure.addSuppressed(releaseFailure);
}
@@ -38,6 +38,7 @@ firstleg:
allow-rejected-measurement: false
default-pallet-capacity-cbm: ${DEFAULT_PALLET_CAPACITY_CBM:1.200000}
reservation-ttl: ${PALLET_RESERVATION_TTL:30m}
warehouse-resource-tenant-id: ${WAREHOUSE_RESOURCE_TENANT_ID:0}
management.endpoints.web.exposure.include: health,info,prometheus
logging:
@@ -223,7 +223,9 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result (
adjusted_at DATETIME(3) NULL COMMENT '修正时间',
confirmed_by BIGINT UNSIGNED NULL COMMENT '确认人ID',
confirmed_at DATETIME(3) NULL COMMENT '确认时间',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
@@ -0,0 +1,23 @@
-- 二期称重结果审计字段增量迁移(可重复执行)。
-- InboundWeightResultEntity 继承 BaseEntity,持久化需要 created_by、updated_by 两个审计字段。
SET @has_created_by := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_weight_result' AND column_name = 'created_by'
);
SET @created_by_sql := IF(@has_created_by = 0,
'ALTER TABLE fl_inbound_weight_result ADD COLUMN created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''创建人ID'' AFTER confirmed_at',
'SELECT 1');
PREPARE created_by_stmt FROM @created_by_sql;
EXECUTE created_by_stmt;
DEALLOCATE PREPARE created_by_stmt;
SET @has_updated_by := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_weight_result' AND column_name = 'updated_by'
);
SET @updated_by_sql := IF(@has_updated_by = 0,
'ALTER TABLE fl_inbound_weight_result ADD COLUMN updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''最后修改人ID'' AFTER created_at',
'SELECT 1');
PREPARE updated_by_stmt FROM @updated_by_sql;
EXECUTE updated_by_stmt;
DEALLOCATE PREPARE updated_by_stmt;
@@ -0,0 +1,137 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
import com.baiyue.firstleg.config.InboundProperties;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MeasurementLocationOccupationServiceTest {
private static final long BUSINESS_TENANT = 10001L;
private static final long RESOURCE_TENANT = 90001L;
private static final long WAREHOUSE = 200L;
private static final long INBOUND = 88L;
private static final long OPERATOR = 9L;
private static final long MEASUREMENT = 30L;
private final WarehousePalletLocationClient warehouse = mock(WarehousePalletLocationClient.class);
private final InboundPalletAllocationRepository allocations = mock(InboundPalletAllocationRepository.class);
private final InboundWorkflowRepository workflow = mock(InboundWorkflowRepository.class);
private final InboundProperties properties = properties();
private final MeasurementLocationOccupationService service =
new MeasurementLocationOccupationService(warehouse, allocations, workflow, properties, "internal-token");
@Test
void sharedWarehouseResourcesUseResourceTenantWhileSnapshotsRemainInBusinessTenant() {
properties.setWarehouseResourceTenantId(RESOURCE_TENANT);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED")));
when(warehouse.confirm(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("OCCUPIED")));
service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3);
ArgumentCaptor<WarehousePalletLocationClient.ReserveRequest> reserve =
ArgumentCaptor.forClass(WarehousePalletLocationClient.ReserveRequest.class);
verify(warehouse).reserve(org.mockito.ArgumentMatchers.eq("internal-token"), reserve.capture());
assertThat(reserve.getValue().tenantId()).isEqualTo(RESOURCE_TENANT);
assertThat(reserve.getValue().businessId()).isEqualTo(INBOUND);
assertThat(reserve.getValue().idempotencyKey()).isEqualTo("measure-88-30-3");
ArgumentCaptor<WarehousePalletLocationClient.ConfirmRequest> confirm =
ArgumentCaptor.forClass(WarehousePalletLocationClient.ConfirmRequest.class);
verify(warehouse).confirm(org.mockito.ArgumentMatchers.eq("internal-token"), confirm.capture());
assertThat(confirm.getValue().tenantId()).isEqualTo(RESOURCE_TENANT);
assertThat(confirm.getValue().businessId()).isEqualTo(INBOUND);
verify(allocations, times(2)).replace(
org.mockito.ArgumentMatchers.eq(BUSINESS_TENANT),
org.mockito.ArgumentMatchers.eq(INBOUND),
org.mockito.ArgumentMatchers.eq(WAREHOUSE),
org.mockito.ArgumentMatchers.eq(new BigDecimal("1.200000")),
org.mockito.ArgumentMatchers.any());
}
@Test
void zeroResourceTenantFallsBackToBusinessTenant() {
properties.setWarehouseResourceTenantId(0L);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED")));
when(warehouse.confirm(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("OCCUPIED")));
service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3);
ArgumentCaptor<WarehousePalletLocationClient.ReserveRequest> reserve =
ArgumentCaptor.forClass(WarehousePalletLocationClient.ReserveRequest.class);
verify(warehouse).reserve(org.mockito.ArgumentMatchers.eq("internal-token"), reserve.capture());
assertThat(reserve.getValue().tenantId()).isEqualTo(BUSINESS_TENANT);
ArgumentCaptor<WarehousePalletLocationClient.ConfirmRequest> confirm =
ArgumentCaptor.forClass(WarehousePalletLocationClient.ConfirmRequest.class);
verify(warehouse).confirm(org.mockito.ArgumentMatchers.eq("internal-token"), confirm.capture());
assertThat(confirm.getValue().tenantId()).isEqualTo(BUSINESS_TENANT);
}
@Test
void confirmFailureReleasesReservationInResourceTenantAndKeepsOriginalFailure() {
properties.setWarehouseResourceTenantId(RESOURCE_TENANT);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED")));
IllegalStateException failure = new IllegalStateException("confirm failed");
when(warehouse.confirm(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenThrow(failure);
when(warehouse.release(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(List.of()));
assertThatThrownBy(() -> service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3))
.isSameAs(failure);
ArgumentCaptor<WarehousePalletLocationClient.ReleaseRequest> release =
ArgumentCaptor.forClass(WarehousePalletLocationClient.ReleaseRequest.class);
verify(warehouse).release(org.mockito.ArgumentMatchers.eq("internal-token"), release.capture());
assertThat(release.getValue().tenantId()).isEqualTo(RESOURCE_TENANT);
assertThat(release.getValue().businessId()).isEqualTo(INBOUND);
verify(allocations).replace(BUSINESS_TENANT, INBOUND, WAREHOUSE,
new BigDecimal("1.200000"), batch("RESERVED"));
}
private static InboundProperties properties() {
InboundProperties value = new InboundProperties();
value.setDefaultPalletCapacityCbm(new BigDecimal("1.200000"));
return value;
}
private static InboundOrderEntity inbound() {
InboundOrderEntity value = new InboundOrderEntity();
value.setTenantId(BUSINESS_TENANT);
value.setId(INBOUND);
value.setWarehouseId(WAREHOUSE);
value.setInboundCode("RK-001");
value.setActualVolumeCbm(new BigDecimal("1.0000"));
return value;
}
private static WarehousePalletLocationClient.BatchResult batch(String state) {
return new WarehousePalletLocationClient.BatchResult(1, List.of(
new WarehousePalletLocationClient.AllocationResult(
501L, "RES-001", 1, 601L, "PL-001", 701L, "A-001",
new BigDecimal("1.0000"), state)));
}
}
@@ -4,12 +4,14 @@ import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 订单微服务启动入口,负责装配订单领域组件并扫描 MyBatis-Plus Mapper。
*/
@MapperScan("com.baiyue.order.mapper")
@EnableFeignClients(basePackages = "com.baiyue.order.client")
@EnableScheduling
@SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueOrderApplication {
public static void main(String[] args) {
@@ -0,0 +1,29 @@
package com.baiyue.order.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import java.time.LocalDateTime;
/** 订单审核事件投递到头程入库域的内部客户端。 */
@FeignClient(name = "firstleg-service", contextId = "orderFirstlegInboundClient",
url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/inbounds")
public interface FirstlegInboundClient {
@PostMapping("/order-approved")
ApiResponse<Long> create(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody OrderApprovedInboundCommand command);
record OrderApprovedInboundCommand(
long tenantId, long orderId, String orderNo, 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,
Boolean customsInspection, String destinationType, Long destinationWarehouseId,
String destinationCode, String destinationName, String publicRemark,
String expressCompanyName, String expressTrackingNo
) {
}
}
@@ -55,6 +55,8 @@ 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,
@NotBlank @Pattern(regexp = "BUY_DECLARATION|TAX_REFUND") String taxMode,
@Pattern(regexp = "FULL_CONTAINER|LCL") String containerMode,
@NotNull Boolean customsInspection,
@@ -38,6 +38,8 @@ public class OrderEntity extends BaseEntity {
private String receivingTime;
private String channelCode;
private String channelName;
private Long carrierId;
private String carrierName;
private String taxMode;
private String containerMode;
private Boolean customsInspection;
@@ -26,6 +26,8 @@ public class OutboxEventEntity {
private String payloadJson;
private String publishStatus;
private Integer retryCount;
private LocalDateTime nextRetryAt;
private LocalDateTime publishedAt;
private LocalDateTime createdAt;
}
@@ -6,12 +6,14 @@ import com.baiyue.order.entity.*;
import com.baiyue.order.mapper.*;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Map;
import java.time.LocalDateTime;
/**
* 订单聚合仓储,组合订单主表、货物、参与方、备注、状态历史与 Outbox 的持久化操作,
@@ -49,6 +51,29 @@ public class OrderRepository {
public void insertNote(OrderInternalNoteEntity note) { noteMapper.insert(note); }
public void insertHistory(OrderStatusHistoryEntity history) { historyMapper.insert(history); }
public void insertOutbox(OutboxEventEntity event) { outboxMapper.insert(event); }
public List<OutboxEventEntity> pendingInboundEvents(int limit) {
return outboxMapper.selectList(new LambdaQueryWrapper<OutboxEventEntity>()
.eq(OutboxEventEntity::getEventType, "FIRST_LEG_ORDER_APPROVED")
.eq(OutboxEventEntity::getPublishStatus, "PENDING")
.and(v -> v.isNull(OutboxEventEntity::getNextRetryAt)
.or().le(OutboxEventEntity::getNextRetryAt, LocalDateTime.now()))
.orderByAsc(OutboxEventEntity::getCreatedAt)
.last("LIMIT " + Math.min(Math.max(limit, 1), 100)));
}
public void markOutboxPublished(long id, LocalDateTime publishedAt) {
outboxMapper.update(null, new LambdaUpdateWrapper<OutboxEventEntity>()
.eq(OutboxEventEntity::getId, id)
.eq(OutboxEventEntity::getPublishStatus, "PENDING")
.set(OutboxEventEntity::getPublishStatus, "PUBLISHED")
.set(OutboxEventEntity::getPublishedAt, publishedAt));
}
public void markOutboxRetry(long id, LocalDateTime nextRetryAt) {
outboxMapper.update(null, new LambdaUpdateWrapper<OutboxEventEntity>()
.eq(OutboxEventEntity::getId, id)
.eq(OutboxEventEntity::getPublishStatus, "PENDING")
.setSql("retry_count = retry_count + 1")
.set(OutboxEventEntity::getNextRetryAt, nextRetryAt));
}
public void insertIdempotency(OrderIdempotencyRecordEntity record) { idempotencyMapper.insert(record); }
public OrderIdempotencyRecordEntity findIdempotency(
@@ -5,6 +5,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.OrderDetailResponse;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.dto.TenantOrderCountResponse;
import com.baiyue.order.client.FirstlegInboundClient;
import com.baiyue.order.entity.*;
import com.baiyue.order.repository.OrderRepository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
@@ -304,7 +305,9 @@ public class OrderApplicationService {
order.setReceivingWarehouseAddress(formatAddress(snapshots.receiving()));
order.setReceivingTime(formatReceivingTime(snapshots.receiving()));
order.setChannelCode(request.channelCode());
order.setChannelName(request.channelName()); order.setTaxMode(request.taxMode());
order.setChannelName(request.channelName());
order.setCarrierId(request.carrierId()); order.setCarrierName(request.carrierName());
order.setTaxMode(request.taxMode());
order.setContainerMode(request.containerMode()); order.setCustomsInspection(request.customsInspection());
order.setDestinationType(request.destinationType());
order.setAmazonWarehouseCode("FBA".equals(request.destinationType()) && snapshots.destination() != null
@@ -366,12 +369,26 @@ public class OrderApplicationService {
event.setTenantId(order.getTenantId()); event.setAggregateType("FIRST_LEG_ORDER"); event.setAggregateId(order.getId());
event.setEventType(target == OrderStatus.APPROVED ? "FIRST_LEG_ORDER_APPROVED" : "FIRST_LEG_ORDER_CANCELLED");
try {
event.setPayloadJson(json.writeValueAsString(Map.of("tenantId", order.getTenantId(), "orderId", order.getId(),
event.setPayloadJson(target == OrderStatus.APPROVED
? json.writeValueAsString(inboundCommand(order))
: json.writeValueAsString(Map.of("tenantId", order.getTenantId(), "orderId", order.getId(),
"orderNo", order.getOrderNo(), "status", target.name())));
} catch (JsonProcessingException exception) { throw new BizException("Cannot serialize order event"); }
event.setPublishStatus("PENDING"); event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now());
repository.insertOutbox(event);
}
private FirstlegInboundClient.OrderApprovedInboundCommand inboundCommand(OrderEntity order) {
return new FirstlegInboundClient.OrderApprovedInboundCommand(
order.getTenantId(), order.getId(), order.getOrderNo(), 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(),
order.getCarrierId(), order.getCarrierName(),
"BUY_DECLARATION".equals(order.getTaxMode()) ? "TAX_INCLUDED" : "TAX_EXCLUDED",
order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(),
order.getDestinationWarehouseId(), order.getAmazonWarehouseCode(), order.getDestinationWarehouseName(),
order.getCustomerRemark(), order.getExpressCompany(), order.getExpressTrackingNo());
}
private String nextNo(long orderId) {
return "FL" + orderId;
}
@@ -0,0 +1,47 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.order.client.FirstlegInboundClient;
import com.baiyue.order.entity.OutboxEventEntity;
import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/** 可靠投递订单审核事件;接收端以订单唯一键保证重复投递安全。 */
@Component
public class OrderInboundOutboxPublisher {
private final OrderRepository repository;
private final FirstlegInboundClient client;
private final ObjectMapper json;
private final String internalToken;
public OrderInboundOutboxPublisher(OrderRepository repository, FirstlegInboundClient client,
ObjectMapper json,
@Value("${baiyue.security.internal-service-token}") String internalToken) {
this.repository = repository;
this.client = client;
this.json = json;
this.internalToken = internalToken;
}
@Scheduled(fixedDelayString = "${baiyue.order.inbound-outbox.fixed-delay-ms:3000}")
public void publishApprovedOrders() {
for (OutboxEventEntity event : repository.pendingInboundEvents(50)) {
try {
FirstlegInboundClient.OrderApprovedInboundCommand command = json.readValue(
event.getPayloadJson(), FirstlegInboundClient.OrderApprovedInboundCommand.class);
ApiResponse<Long> response = client.create(internalToken, command);
if (response == null || !response.successful()) {
throw new IllegalStateException("头程入库服务返回失败");
}
repository.markOutboxPublished(event.getId(), LocalDateTime.now());
} catch (Exception exception) {
repository.markOutboxRetry(event.getId(), LocalDateTime.now().plusSeconds(10));
}
}
}
}
+2
View File
@@ -26,6 +26,8 @@ CREATE TABLE IF NOT EXISTS ord_order (
receiving_time VARCHAR(128) NULL COMMENT '国内收货仓收货时间快照',
channel_code VARCHAR(64) NULL COMMENT '渠道编码',
channel_name VARCHAR(128) NULL COMMENT '渠道名称快照',
carrier_id BIGINT UNSIGNED NULL COMMENT '船司逻辑ID快照',
carrier_name VARCHAR(128) NULL COMMENT '船司名称快照',
tax_mode VARCHAR(24) NULL COMMENT '买单或退税',
container_mode VARCHAR(24) NULL COMMENT '整柜或散货',
customs_inspection TINYINT(1) NULL COMMENT '是否商检',
@@ -0,0 +1,24 @@
SET NAMES utf8mb4;
USE baiyue_order;
SET @carrier_id_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'ord_order' AND column_name = 'carrier_id'
);
SET @carrier_id_sql := IF(@carrier_id_exists = 0,
'ALTER TABLE ord_order ADD COLUMN carrier_id BIGINT UNSIGNED NULL COMMENT ''船司逻辑ID快照'' AFTER channel_name',
'SELECT 1');
PREPARE carrier_id_statement FROM @carrier_id_sql;
EXECUTE carrier_id_statement;
DEALLOCATE PREPARE carrier_id_statement;
SET @carrier_name_exists := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'ord_order' AND column_name = 'carrier_name'
);
SET @carrier_name_sql := IF(@carrier_name_exists = 0,
'ALTER TABLE ord_order ADD COLUMN carrier_name VARCHAR(128) NULL COMMENT ''船司名称快照'' AFTER carrier_id',
'SELECT 1');
PREPARE carrier_name_statement FROM @carrier_name_sql;
EXECUTE carrier_name_statement;
DEALLOCATE PREPARE carrier_name_statement;
@@ -180,7 +180,7 @@ class OrderApplicationServiceStateTest {
null, null, null, null, null, null, null, null, null);
return new OrderRequests.SaveRequest(
7L, "客户", "SEA", null, "US", null, "目的仓", null, null,
party, party, List.of(cargo), "PICKUP", 8L, "收货仓", "CH", "渠道",
party, party, List.of(cargo), "PICKUP", 8L, "收货仓", "CH", "渠道", 9L, "船司",
"BUY_DECLARATION", "LCL", false, "OVERSEAS", null, null, null);
}
@@ -193,7 +193,7 @@ class OrderApplicationServiceStateTest {
null, null, null, null, null, null, null, null, null);
return new OrderRequests.SaveRequest(
customerId, "当前企业", "SEA", null, "US", null, "目的仓", null, null,
sender, receiver, List.of(cargo), pickupMethod, 8L, "收货仓", "CH", "渠道",
sender, receiver, List.of(cargo), pickupMethod, 8L, "收货仓", "CH", "渠道", 9L, "船司",
"BUY_DECLARATION", "LCL", false, destinationType, null, null, null);
}
@@ -223,7 +223,7 @@ class OrderMasterDataGuardTest {
var party=new OrderRequests.Party("公司","联系人","13800138000",null,"CN",null,"深圳",null,"地址",null);
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);
return new OrderRequests.SaveRequest(partner,"不可信客户名","SEA",null,"CN",destinationWarehouseId,"私人地址",null,null,party,party,List.of(cargo),"PICKUP",warehouse,"不可信收货仓","CH","渠道","BUY_DECLARATION","LCL",false,destinationType,null,null,null);
return new OrderRequests.SaveRequest(partner,"不可信客户名","SEA",null,"CN",destinationWarehouseId,"私人地址",null,null,party,party,List.of(cargo),"PICKUP",warehouse,"不可信收货仓","CH","渠道",9L,"船司","BUY_DECLARATION","LCL",false,destinationType,null,null,null);
}
private WarehouseClient.Detail warehouse(long id,String type,boolean receiving) {
var value=new WarehouseClient.Warehouse(id,PUBLIC_TENANT_ID,"WH-"+id,"仓库"+id,type,"CN","ACTIVE");
+5 -2
View File
@@ -5,6 +5,8 @@ param(
[string]$OrderUrl = 'http://127.0.0.1:9504',
[string]$WarehouseUrl = 'http://127.0.0.1:9505',
[string]$ImagePath,
[long]$PublicReceivingWarehouseId = 0,
[long]$PublicDestinationWarehouseId = 0,
[string]$PlatformPassword = 'ChangeMe123!'
)
$ErrorActionPreference = 'Stop'
@@ -62,13 +64,14 @@ $warehouse = Invoke-Json POST "$WarehouseUrl/api/platform/v1/warehouse-admin" @{
} $platformToken
Assert-Success 'create tenant receiving warehouse' $warehouse
$warehouseId = [long]$warehouse.data
if ($PublicReceivingWarehouseId -gt 0) { $warehouseId = $PublicReceivingWarehouseId }
$order = @{
customerId=$partnerId; customerName="Phase One Partner $stamp"; transportMode='SEA'; tradeTerm='FOB'
destinationCountryCode='US'; destinationWarehouseId=$null; destinationWarehouseName='ONT8'
destinationCountryCode='US'; destinationWarehouseId=$(if ($PublicDestinationWarehouseId -gt 0) {$PublicDestinationWarehouseId} else {$null}); destinationWarehouseName='ONT8'
expectedShipDate='2026-08-15'; customerRemark='Phase one E2E'; pickupMethod='PICKUP'
receivingWarehouseId=$warehouseId; receivingWarehouseName="Phase One Receiving Warehouse $stamp"; channelCode='SEA-US-FBA'
channelName='US Sea Channel'; taxMode='BUY_DECLARATION'; containerMode='LCL'; customsInspection=$false
channelName='US Sea Channel'; carrierId=9900001; carrierName='模拟海运船司'; taxMode='BUY_DECLARATION'; containerMode='LCL'; customsInspection=$false
destinationType='FBA'; amazonWarehouseCode='ONT8'; shipmentId="FBA$stamp"; referenceId="REF$stamp"
sender=@{companyName="Phase One Enterprise $stamp";contactName='Test Sender';phone='13800138000';email="p$stamp@example.com";countryCode='CN';province='Guangdong';city='Shenzhen';district='Nanshan';addressLine='No. 1 Test Road';postalCode='518000'}
receiver=@{companyName='Amazon';contactName='ONT8';phone='1234567890';email='ont8@example.com';countryCode='US';province='CA';city='Moreno Valley';district='Riverside';addressLine='24300 Nandina Ave';postalCode='92551'}
@@ -140,6 +140,7 @@ public final class LocationDtos {
public record ActiveResource(
long reservationId,
String reservationNo,
Long occupancyId,
long warehouseId,
long palletId,
@@ -149,5 +150,11 @@ public final class LocationDtos {
boolean sharedOverflow,
String state
) {
public ActiveResource(long reservationId, Long occupancyId, long warehouseId, long palletId,
String palletCode, long locationId, String locationCode,
boolean sharedOverflow, String state) {
this(reservationId, null, occupancyId, warehouseId, palletId, palletCode, locationId,
locationCode, sharedOverflow, state);
}
}
}
@@ -30,6 +30,8 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -163,7 +165,7 @@ public class LocationResourceService {
"OCCUPIED", "AVAILABLE", location.getVersion(), command.operatorId()) != 1)) {
throw new BizException(ResultCode.CONFLICT, "释放占用时库位状态已被并发修改");
}
released.add(active(occupancy, "RELEASED"));
released.add(active(occupancy, "RELEASED", null));
}
for (LocationReservationEntity reservation : repository.findReservationsByBusiness(
command.tenantId(), command.sourceService(), command.businessId())) {
@@ -187,13 +189,17 @@ public class LocationResourceService {
@NotBlank String sourceService,
@Min(1) long businessId) {
List<ActiveResource> result = new ArrayList<>();
repository.findReservationsByBusiness(tenantId, sourceService, businessId).stream()
List<LocationReservationEntity> reservations = repository.findReservationsByBusiness(
tenantId, sourceService, businessId);
Map<Long, String> reservationNos = reservations.stream().collect(Collectors.toMap(
LocationReservationEntity::getId, LocationReservationEntity::getReservationNo));
reservations.stream()
.filter(item -> "RESERVED".equals(item.getReservationStatus()))
.map(item -> active(item, "RESERVED"))
.forEach(result::add);
repository.findOccupanciesByBusiness(tenantId, sourceService, businessId).stream()
.filter(item -> "OCCUPIED".equals(item.getOccupancyStatus()))
.map(item -> active(item, "OCCUPIED"))
.map(item -> active(item, "OCCUPIED", reservationNos.get(item.getReservationId())))
.forEach(result::add);
return List.copyOf(result);
}
@@ -326,13 +332,13 @@ public class LocationResourceService {
}
private static ActiveResource active(LocationReservationEntity entity, String state) {
return new ActiveResource(entity.getId(), null, entity.getWarehouseId(), entity.getPalletId(),
return new ActiveResource(entity.getId(), entity.getReservationNo(), null, entity.getWarehouseId(), entity.getPalletId(),
entity.getPalletCode(), entity.getLocationId(), entity.getLocationCode(),
Boolean.TRUE.equals(entity.getSharedOverflow()), state);
}
private static ActiveResource active(LocationOccupancyEntity entity, String state) {
return new ActiveResource(entity.getReservationId(), entity.getId(), entity.getWarehouseId(),
private static ActiveResource active(LocationOccupancyEntity entity, String state, String reservationNo) {
return new ActiveResource(entity.getReservationId(), reservationNo, entity.getId(), entity.getWarehouseId(),
entity.getPalletId(), entity.getPalletCode(), entity.getLocationId(), entity.getLocationCode(),
Boolean.TRUE.equals(entity.getSharedOverflow()), state);
}
@@ -107,7 +107,7 @@ public class PalletLocationAllocationService {
List<PalletAllocationDtos.AllocationResult> results = new ArrayList<>(occupied.size());
for (int index = 0; index < occupied.size(); index++) {
var item = occupied.get(index);
results.add(new PalletAllocationDtos.AllocationResult(item.reservationId(), null, index + 1,
results.add(new PalletAllocationDtos.AllocationResult(item.reservationId(), active.get(index).reservationNo(), index + 1,
item.palletId(), active.get(index).palletCode(), item.locationId(), item.locationCode(),
split.allocations().get(index).allocatedVolumeCbm(), "OCCUPIED"));
}
@@ -146,7 +146,7 @@ public class PalletLocationAllocationService {
List<PalletAllocationDtos.AllocationResult> results = new ArrayList<>(active.size());
for (int index = 0; index < active.size(); index++) {
var item = active.get(index);
results.add(new PalletAllocationDtos.AllocationResult(item.reservationId(), null, index + 1,
results.add(new PalletAllocationDtos.AllocationResult(item.reservationId(), item.reservationNo(), index + 1,
item.palletId(), item.palletCode(), item.locationId(), item.locationCode(),
split.allocations().get(index).allocatedVolumeCbm(), item.state()));
}