diff --git a/config/nacos/local/firstleg-service.yml b/config/nacos/local/firstleg-service.yml index 7c5553551..2b2b4a974 100644 --- a/config/nacos/local/firstleg-service.yml +++ b/config/nacos/local/firstleg-service.yml @@ -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} diff --git a/deploy/sql/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql b/deploy/sql/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql index a7f9fd5ef..061c9d846 100644 --- a/deploy/sql/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql +++ b/deploy/sql/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql @@ -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 '乐观锁版本号', diff --git a/firstleg/src/main/java/com/baiyue/firstleg/config/InboundProperties.java b/firstleg/src/main/java/com/baiyue/firstleg/config/InboundProperties.java index 6bb5ee674..f366c525a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/config/InboundProperties.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/config/InboundProperties.java @@ -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; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java new file mode 100644 index 000000000..66eae62a4 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -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 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)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java index 5e7510025..65280f4fb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java @@ -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(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java new file mode 100644 index 000000000..7f9a0857a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java @@ -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 +) { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java index d1c5799dc..cf80fc813 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java index 6279b5acd..b36ddf06c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java @@ -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 reservationNos = mapper.selectList(new LambdaQueryWrapper() + .eq(InboundPalletAllocationEntity::getTenantId, tenantId) + .eq(InboundPalletAllocationEntity::getInboundId, inboundId)) + .stream().collect(Collectors.toMap(InboundPalletAllocationEntity::getPalletId, + InboundPalletAllocationEntity::getReservationNo, (left, right) -> left)); mapper.delete(new LambdaQueryWrapper() .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); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java new file mode 100644 index 000000000..b4f6528d9 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java @@ -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() + .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(); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java index 6879b4a54..0e7d718be 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java @@ -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); } diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index ebcb8b2ad..64d309867 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -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: diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 8a4609214..c0c6db9cd 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -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 '乐观锁版本号', diff --git a/firstleg/src/main/resources/db/phase2-inbound-weight-audit-migration.sql b/firstleg/src/main/resources/db/phase2-inbound-weight-audit-migration.sql new file mode 100644 index 000000000..04a8ec2d7 --- /dev/null +++ b/firstleg/src/main/resources/db/phase2-inbound-weight-audit-migration.sql @@ -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; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java new file mode 100644 index 000000000..dbf75af6b --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java @@ -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 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 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 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 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 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))); + } +} diff --git a/order/src/main/java/com/baiyue/order/BaiyueOrderApplication.java b/order/src/main/java/com/baiyue/order/BaiyueOrderApplication.java index 467388cbb..12b70de88 100644 --- a/order/src/main/java/com/baiyue/order/BaiyueOrderApplication.java +++ b/order/src/main/java/com/baiyue/order/BaiyueOrderApplication.java @@ -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) { diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java new file mode 100644 index 000000000..abbb9b831 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java @@ -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 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 + ) { + } +} diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java index 6a6e8a2aa..8e66b50ca 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java @@ -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, diff --git a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java index e172cdcf3..44d97d8eb 100644 --- a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java +++ b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java @@ -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; diff --git a/order/src/main/java/com/baiyue/order/entity/OutboxEventEntity.java b/order/src/main/java/com/baiyue/order/entity/OutboxEventEntity.java index f6e8b7c41..d450072d2 100644 --- a/order/src/main/java/com/baiyue/order/entity/OutboxEventEntity.java +++ b/order/src/main/java/com/baiyue/order/entity/OutboxEventEntity.java @@ -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; } diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index cb0433227..d420896bc 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -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 pendingInboundEvents(int limit) { + return outboxMapper.selectList(new LambdaQueryWrapper() + .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() + .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() + .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( diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 2f02c1b1e..95bfd1fd2 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -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; } diff --git a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java new file mode 100644 index 000000000..419beca7a --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java @@ -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 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)); + } + } + } +} diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index f4697a289..47368a380 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -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 '是否商检', diff --git a/order/src/main/resources/db/phase2-order-carrier-migration.sql b/order/src/main/resources/db/phase2-order-carrier-migration.sql new file mode 100644 index 000000000..e4b73a6e4 --- /dev/null +++ b/order/src/main/resources/db/phase2-order-carrier-migration.sql @@ -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; diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 58c04218c..ba58f07f3 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -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); } diff --git a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java index d8362fb75..de91a6a5f 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java @@ -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"); diff --git a/scripts/phase1-onboarding-order-e2e.ps1 b/scripts/phase1-onboarding-order-e2e.ps1 index 5b7b5f555..083e164e9 100644 --- a/scripts/phase1-onboarding-order-e2e.ps1 +++ b/scripts/phase1-onboarding-order-e2e.ps1 @@ -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'} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationDtos.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationDtos.java index f5a5ec924..44462941c 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationDtos.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationDtos.java @@ -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); + } } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java index b1176b999..330d634e3 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java @@ -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 result = new ArrayList<>(); - repository.findReservationsByBusiness(tenantId, sourceService, businessId).stream() + List reservations = repository.findReservationsByBusiness( + tenantId, sourceService, businessId); + Map 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); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java index e385d9ae1..83e085d45 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java @@ -107,7 +107,7 @@ public class PalletLocationAllocationService { List 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 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())); }