From 1cd8b6a4733e2a5a8bc8655c1229173adfc6391a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 20 Aug 2026 18:02:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- Jenkinsfile | 12 +- Jenkinsfile.service | 7 +- .../auth/service/OverseasTokenService.java | 4 +- .../service/OverseasTokenServiceTest.java | 15 +- common-event-contract/AGENTS.md | 6 +- .../event/OverseasContainerUnloadedV1.java | 3 +- .../OverseasContainerUnloadedV1Test.java | 4 +- config/nacos/cn/event-bridge-service.yml | 2 + config/nacos/cn/firstleg-service.yml | 7 + .../nacos/overseas/event-bridge-service.yml | 2 +- config/nacos/overseas/lastleg-service.yml | 13 + deploy/docker-compose.overseas.yml | 97 ++++++ deploy/docker-compose.yml | 7 +- deploy/scripts/deploy-built-service.sh | 98 +++++- deploy/scripts/deploy-development-service.sh | 16 +- deploy/scripts/deploy-services.sh | 110 +++++- deploy/service-map.yml | 18 +- deploy/sql/README.md | 10 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 215 +++++++++--- deploy/sql/海外区域统一增量-MySQL8.sql | 254 ++++++++++++++ docs/头程项目目录结构与服务说明.md | 7 +- .../controller/EventBridgeController.java | 2 +- .../service/EventInboxService.java | 28 +- .../src/main/resources/application.yml | 4 +- .../service/EventInboxServiceTest.java | 43 ++- firstleg/AGENTS.md | 6 +- .../CarrierScheduleIntegrationClient.java | 28 ++ .../firstleg/dto/ContainerOverviewDtos.java | 8 + .../mapper/ContainerBookingMapper.java | 2 +- .../service/OutboundDispatchService.java | 17 +- ...nternalCarrierScheduleQueryController.java | 48 +++ ...latformCarrierScheduleQueryController.java | 17 +- .../domain/CarrierScheduleNode.java | 29 ++ .../dto/CarrierScheduleQueryDtos.java | 41 ++- .../entity/CarrierScheduleNodeFactEntity.java | 32 ++ .../entity/CarrierScheduleQueryEntity.java | 8 + .../mapper/CarrierScheduleNodeFactMapper.java | 23 ++ .../mapper/CarrierScheduleQueryMapper.java | 36 ++ .../CarrierScheduleQueryRepository.java | 19 ++ .../CarrierScheduleAdapterRegistry.java | 17 + .../CarrierScheduleQueryScheduler.java | 71 ++++ .../service/CarrierScheduleQueryService.java | 253 +++++++++++--- .../LoadingCompletedOutboxRepository.java | 20 +- .../service/ContainerOverviewService.java | 9 +- .../service/LoadingCompletedOutboxRelay.java | 20 +- .../OverseasContainerEventConsumer.java | 30 +- .../phase3-carrier-schedule-query-schema.sql | 76 ++++- .../db/phase3-ocean-booking-schema.sql | 30 +- .../db/phase3-truck-appointment-schema.sql | 8 +- .../openapi/carrier-schedule-internal-v1.yaml | 23 ++ .../openapi/carrier-schedule-query-v1.yaml | 32 +- ...ontainerOverviewTransportContractTest.java | 21 ++ .../dispatch/OutboundDispatchServiceTest.java | 21 ++ ...rrierScheduleQueryOpenApiContractTest.java | 12 +- ...erScheduleQueryControllerContractTest.java | 5 +- .../CarrierScheduleQuerySqlContractTest.java | 13 +- .../CarrierScheduleQuerySchedulerTest.java | 41 +++ .../CarrierScheduleQueryServiceTest.java | 58 ++-- .../Phase3FinalUnifiedSqlContractTest.java | 2 +- .../LoadingCompletedOutboxRelayTest.java | 96 ++++++ .../OverseasContainerEventConsumerTest.java | 12 +- .../gateway/config/GatewaySecurityConfig.java | 1 - .../config/RequiredClaimsJwtValidator.java | 40 ++- .../main/resources/application-overseas.yml | 2 + .../RequiredClaimsJwtValidatorTest.java | 49 ++- .../main/resources/db/lastleg-permissions.sql | 71 +++- ...erseasPhase4PermissionSqlContractTest.java | 77 +++++ integration-service/AGENTS.md | 2 +- .../IntegrationProviderController.java | 16 + .../domain/CarrierScheduleStandardNode.java | 17 + .../integration/dto/CarrierScheduleDtos.java | 23 ++ .../CarrierScheduleProviderAdapter.java | 9 + ...mpliantCarrierScheduleProviderAdapter.java | 35 ++ .../CarrierScheduleProviderService.java | 39 +++ .../openapi/carrier-schedule-provider-v1.yaml | 55 +++ .../CarrierScheduleProviderServiceTest.java | 41 +++ lastleg-service/AGENTS.md | 23 +- lastleg-service/pom.xml | 3 +- .../lastleg/BaiyueLastlegApplication.java | 4 + .../lastleg/client/EventBridgeClient.java | 20 ++ .../controller/LastlegOrderController.java | 17 +- .../DeliveryTransferController.java | 2 + .../delivery/dto/DeliveryResponses.java | 9 +- .../service/DeliveryTransferService.java | 10 +- .../controller/DispatchController.java | 2 + .../pda/controller/DispatchPdaController.java | 4 +- .../dto/LastlegOrderDetailResponse.java | 22 +- .../lastleg/dto/LastlegOrderSummary.java | 8 +- .../lastleg/entity/LastlegEventOutbox.java | 2 + .../controller/InboundPlanController.java | 30 +- .../inboundplan/dto/InboundPlanRequests.java | 30 ++ .../inboundplan/dto/InboundPlanSummary.java | 32 ++ .../inboundplan/mapper/InboundPlanMapper.java | 37 +++ .../InboundPalletAssignmentController.java | 2 +- .../pda/controller/InboundPdaController.java | 2 +- .../pda/entity/InboundStockReceipt.java | 3 + .../service/InboundStockReceiptService.java | 90 ++++- .../InboundUnloadCompletedOutboxRelay.java | 54 +++ .../service/InboundPlanService.java | 16 + .../InventoryCommandController.java | 47 +-- .../command/dto/InventoryCommandRequests.java | 3 + .../service/InventoryCommandService.java | 14 +- .../inventory/entity/InventoryBalance.java | 3 + .../mapper/InventoryBalanceMapper.java | 10 +- .../controller/InventoryQueryController.java | 20 +- .../query/dto/InventoryBalanceView.java | 3 + .../query/dto/InventoryQueryRequests.java | 2 + .../query/mapper/InventoryQueryMapper.java | 4 + .../query/service/InventoryQueryService.java | 7 +- .../LooseboardMapperConfiguration.java | 9 + .../PlatformLocalLooseboardController.java | 97 ++++++ .../dto/LocalLooseboardRequests.java | 72 ++++ .../dto/LocalLooseboardResponses.java | 25 ++ .../entity/LocalLooseboardHistory.java} | 17 +- .../entity/LocalLooseboardOrder.java | 42 +++ .../mapper/LocalLooseboardHistoryMapper.java | 15 + .../mapper/LocalLooseboardOrderMapper.java | 19 ++ .../service/LocalLooseboardService.java | 302 +++++++++++++++++ ...PlatformMonthlyExchangeRateController.java | 58 ++++ .../exchange/dto/MonthlyExchangeRateDtos.java | 36 ++ .../exchange/entity/MonthlyExchangeRate.java | 26 ++ .../entity/MonthlyExchangeRateHistory.java | 23 ++ .../MonthlyExchangeRateHistoryMapper.java | 8 + .../mapper/MonthlyExchangeRateMapper.java | 19 ++ .../service/MonthlyExchangeRateService.java | 156 +++++++++ .../DomesticPartnerWarehouseClient.java | 18 + .../PlatformOverseasQuoteController.java | 107 ++++++ .../quote/dto/OverseasQuoteRequests.java | 60 ++++ .../quote/dto/OverseasQuoteResponses.java | 30 ++ .../quote/entity/OverseasQuote.java | 32 ++ .../quote/entity/OverseasQuoteFee.java | 21 ++ .../entity/OverseasQuoteTransitPrice.java | 22 ++ .../quote/mapper/OverseasQuoteFeeMapper.java | 8 + .../quote/mapper/OverseasQuoteMapper.java | 28 ++ .../OverseasQuoteTransitPriceMapper.java | 8 + .../quote/service/OverseasQuoteService.java | 227 +++++++++++++ .../service/PartnerWarehouseDirectory.java | 57 ++++ .../controller/PlatformVehicleController.java | 58 ++++ .../masterdata/vehicle/dto/VehicleDtos.java | 47 +++ .../masterdata/vehicle/entity/Vehicle.java | 30 ++ .../vehicle/mapper/VehicleMapper.java | 13 + .../vehicle/service/VehicleService.java | 143 ++++++++ ...PlatformOverseasProcurementController.java | 43 ++- .../dto/OverseasProcurementRequests.java | 29 +- .../dto/OverseasProcurementResponses.java | 14 +- .../entity/OverseasProcurementOrder.java | 1 - .../OverseasProcurementStatusHistory.java | 2 + .../OverseasProcurementOrderMapper.java | 13 +- .../OverseasProcurementOutboundMapper.java | 16 - .../service/OverseasProcurementService.java | 207 ++++++++---- .../RelabelApplicationController.java | 11 + .../UnloadCompletedOutboxRepository.java | 82 +++++ .../lastleg/security/OverseasPermissions.java | 46 +++ .../lastleg/service/LastlegOrderService.java | 50 +-- .../service/WarehouseTransferService.java | 5 + .../MerchantUsTruckOrderController.java | 2 +- .../PlatformUsTruckOrderController.java | 41 ++- .../trucking/service/UsTruckOrderService.java | 10 +- .../src/main/resources/application.yml | 12 +- .../src/main/resources/db/lastleg.sql | 134 +++++++- .../openapi/overseas-business-v1.yaml | 312 ++++++++++++++++++ .../openapi/overseas-master-data-v1.yaml | 273 +++++++++++++++ .../LastlegOrderControllerTest.java | 8 +- .../Phase4BrowserApiBoundaryTest.java | 61 ++++ .../service/DeliveryTransferServiceTest.java | 25 +- .../InboundPlanSchemaContractTest.java | 6 +- .../InboundPlanPermissionContractTest.java | 28 ++ .../InboundStockReceiptServiceTest.java | 8 +- ...InboundUnloadCompletedOutboxRelayTest.java | 98 ++++++ .../service/InboundPlanServiceTest.java | 32 ++ .../InventorySchemaContractTest.java | 10 +- .../command/InventoryCommandServiceTest.java | 10 +- .../InventoryQueryControllerTest.java | 2 +- .../service/InventoryQueryServiceTest.java | 5 +- .../LocalLooseboardSchemaContractTest.java | 21 ++ ...PlatformLocalLooseboardControllerTest.java | 60 ++++ .../service/LocalLooseboardServiceTest.java | 113 +++++++ ...OverseasMasterDataOpenApiContractTest.java | 24 ++ .../MonthlyExchangeRateSqlContractTest.java | 19 ++ ...hlyExchangeRateControllerContractTest.java | 29 ++ .../MonthlyExchangeRateServiceTest.java | 39 +++ ...rmOverseasQuoteControllerContractTest.java | 30 ++ .../PartnerWarehouseDirectoryTest.java | 56 ++++ .../vehicle/VehicleSqlContractTest.java | 21 ++ ...PlatformVehicleControllerContractTest.java | 28 ++ .../vehicle/service/VehicleServiceTest.java | 63 ++++ ...rseasProcurementInventoryBoundaryTest.java | 3 + ...verseasProcurementOpenApiContractTest.java | 42 +++ ...OverseasProcurementSchemaContractTest.java | 8 +- .../OverseasProcurementControllerTest.java | 9 + .../OverseasProcurementServiceTest.java | 83 ++++- .../SchemaChineseCommentContractTest.java | 40 ++- .../service/LastlegOrderServiceTest.java | 41 +-- .../service/WarehouseTransferServiceTest.java | 4 +- .../UsTruckOrderControllerTest.java | 19 ++ scripts/db/incremental.sql | 117 +++++++ scripts/finance-deployment-contract.test.mjs | 4 +- scripts/overseas-deployment-contract.test.mjs | 69 ++++ warehouse-overseas-service/AGENTS.md | 1 + .../PlatformWarehouseController.java | 20 +- .../PlatformWarehouseResourceController.java | 85 +++-- .../WarehouseResourceLockRepository.java | 24 +- .../WarehouseResourceRepository.java | 4 + .../OverseasWarehousePermissions.java | 19 ++ .../service/WarehouseResourceService.java | 10 +- .../src/main/resources/application.yml | 6 +- .../WarehouseResourceControllerTest.java | 19 +- ...rehouseResourcePermissionContractTest.java | 41 +++ .../service/WarehouseResourceServiceTest.java | 17 + 210 files changed, 6974 insertions(+), 825 deletions(-) create mode 100644 deploy/docker-compose.overseas.yml create mode 100644 deploy/sql/海外区域统一增量-MySQL8.sql create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalCarrierScheduleQueryController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleNode.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java create mode 100644 firstleg/src/main/resources/openapi/carrier-schedule-internal-v1.yaml create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerOverviewTransportContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/dto/CarrierScheduleDtos.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/CarrierScheduleProviderAdapter.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/CompliantCarrierScheduleProviderAdapter.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java create mode 100644 integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml create mode 100644 integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanSummary.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/LooseboardMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java rename lastleg-service/src/main/java/com/baiyue/lastleg/{procurement/entity/OverseasProcurementOutbound.java => looseboard/entity/LocalLooseboardHistory.java} (54%) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardHistoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/dto/MonthlyExchangeRateDtos.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/entity/MonthlyExchangeRate.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/entity/MonthlyExchangeRateHistory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/mapper/MonthlyExchangeRateHistoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/mapper/MonthlyExchangeRateMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/service/MonthlyExchangeRateService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClient.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/dto/OverseasQuoteRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/dto/OverseasQuoteResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/entity/OverseasQuote.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/entity/OverseasQuoteFee.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/entity/OverseasQuoteTransitPrice.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/mapper/OverseasQuoteFeeMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/mapper/OverseasQuoteMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/mapper/OverseasQuoteTransitPriceMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/OverseasQuoteService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/dto/VehicleDtos.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/entity/Vehicle.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/mapper/VehicleMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleService.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOutboundMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java create mode 100644 lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml create mode 100644 lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/OverseasMasterDataOpenApiContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/MonthlyExchangeRateSqlContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateControllerContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/service/MonthlyExchangeRateServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteControllerContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectoryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/VehicleSqlContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleControllerContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java create mode 100644 scripts/overseas-deployment-contract.test.mjs create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/OverseasWarehousePermissions.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java diff --git a/AGENTS.md b/AGENTS.md index e7699abfa..afc6b887d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ - 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。 - 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验,海外服务不得访问国内 Redis,只按限定 Audience 的海外 Token Claims 完成本地认证和授权;海外业务请求不得经国内 Gateway 中转。 -- 头程与海外仓的入库单、装柜结果、费用来源和财务回传走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。 +- 头程装柜完成向海外建尾程单、海外 PDA 卸柜完成向国内回写货柜报空事实,以及后续费用来源和财务回传,统一走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。 - 海外浏览器请求只进入 `gateway-overseas/`,由其路由到 `lastleg-service/`、`warehouse-overseas-service/` 及海外部署的公共服务;不得经国内 `gateway/` 中转。`lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。 ## 契约、数据库与验证入口 diff --git a/Jenkinsfile b/Jenkinsfile index 941569a2f..94ce52fb3 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,10 +1,11 @@ def serviceModules = [ - 'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'event-bridge-service', 'audit-service' + 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' ] def serviceCheckboxes = [ 'gateway': 'SERVICE_GATEWAY', + 'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS', 'auth': 'SERVICE_AUTH', 'iam': 'SERVICE_IAM', 'firstleg': 'SERVICE_FIRSTLEG', @@ -14,6 +15,8 @@ def serviceCheckboxes = [ 'file': 'SERVICE_FILE', 'print': 'SERVICE_PRINT', 'integration-service': 'SERVICE_INTEGRATION', + 'lastleg-service': 'SERVICE_LASTLEG', + 'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS', 'event-bridge-service': 'SERVICE_EVENT_BRIDGE', 'audit-service': 'SERVICE_AUDIT' ] @@ -47,6 +50,7 @@ pipeline { parameters { choice(name: 'BUILD_MODE', choices: ['AUTO', 'SELECT', 'ALL'], description: 'AUTO 按 Git 变更识别;SELECT 勾选服务;ALL 构建全部服务') booleanParam(name: 'SERVICE_GATEWAY', defaultValue: false, description: 'Gateway 网关服务(gateway)') + booleanParam(name: 'SERVICE_GATEWAY_OVERSEAS', defaultValue: false, description: '海外 Gateway 网关服务(gateway-overseas)') booleanParam(name: 'SERVICE_AUTH', defaultValue: false, description: '统一认证服务(auth)') booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '租户与权限服务(iam)') booleanParam(name: 'SERVICE_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)') @@ -56,7 +60,9 @@ pipeline { booleanParam(name: 'SERVICE_FILE', defaultValue: false, description: '文件中心(file)') booleanParam(name: 'SERVICE_PRINT', defaultValue: false, description: '打印服务(print)') booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)') - booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service)') + booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)') + booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)') + booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;由环境凭据 REGION_CODE 显式选择 CN 或 OVERSEAS 实例)') booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)') choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境') booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署') diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 8216948b2..a84035a53 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -1,10 +1,11 @@ def serviceModules = [ - 'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'event-bridge-service', 'audit-service' + 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' ] def serviceJars = [ 'gateway': 'gateway/target/gateway-1.0.0-SNAPSHOT.jar', + 'gateway-overseas': 'gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar', 'auth': 'auth/target/auth-service-1.0.0-SNAPSHOT.jar', 'iam': 'iam/target/iam-service-1.0.0-SNAPSHOT.jar', 'firstleg': 'firstleg/target/firstleg-service-1.0.0-SNAPSHOT.jar', @@ -14,6 +15,8 @@ def serviceJars = [ 'file': 'file/target/file-service-1.0.0-SNAPSHOT.jar', 'print': 'print/target/print-service-1.0.0-SNAPSHOT.jar', 'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar', + 'lastleg-service': 'lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar', + 'warehouse-overseas-service': 'warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar', 'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar', 'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar' ] diff --git a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java index 2da6a5e9c..3da3caeb0 100644 --- a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java +++ b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java @@ -46,9 +46,9 @@ public class OverseasTokenService { .issuer(properties.issuer()).audience(List.of(properties.audience())) .subject(account.getId().toString()).id(UUID.randomUUID().toString()) .issuedAt(issuedAt).expiresAt(expiresAt) - .claim("user_id", account.getId()).claim("tenant_id", account.getDefaultTenantId()) + .claim("user_id", account.getId()).claim("tenant_id", authorization.getTenantId()) .claim("membership_id", authorization.getMembershipId()) - .claim("app_code", account.getDefaultAppCode()).claim("client_type", authorization.getClientType()) + .claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType()) .claim("roles", authorization.getRoles()) .claim("authorities", authorization.getPermissions()) .claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()) diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java index 49b8ee5b0..642adfe56 100644 --- a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java @@ -45,10 +45,11 @@ class OverseasTokenServiceTest { @Test void signsShortRs256TokenWithCrossRegionClaims() { AuthAccount account = new AuthAccount(); - account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(100L); + account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(999L); account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT"); - AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 100L, 84L, "MERCHANT", "WEB", - Set.of("MERCHANT_ADMIN"), Set.of("merchant:order:list"), 7L); + String authority = "platform:overseas-master-data:quote:list"; + AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB", + Set.of("PLATFORM_ADMIN"), Set.of(authority), 7L); Jwt encoded = org.mockito.Mockito.mock(Jwt.class); when(accounts.findById(42L)).thenReturn(account); when(authorizationLoader.load(account)).thenReturn(snapshot); @@ -64,12 +65,12 @@ class OverseasTokenServiceTest { assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid"); assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api"); assertThat(parameters.getClaims().getClaims()) - .containsEntry("user_id", 42L).containsEntry("tenant_id", 100L) + .containsEntry("user_id", 42L).containsEntry("tenant_id", 0L) .containsEntry("membership_id", 84L) - .containsEntry("app_code", "MERCHANT").containsEntry("client_type", "WEB") + .containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB") .containsEntry("permission_version", 7L) - .containsEntry("roles", Set.of("MERCHANT_ADMIN")) - .containsEntry("authorities", Set.of("merchant:order:list")) + .containsEntry("roles", Set.of("PLATFORM_ADMIN")) + .containsEntry("authorities", Set.of(authority)) .doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion"); assertThat(response.expiresIn()).isEqualTo(900); } diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index ec95783fb..6b92556e4 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -14,13 +14,15 @@ | 事件 ID 与业务键 | `EventIdentifiers.java` | | Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` | | 事件类型与区域 | `EventTypes.java`、`RegionCode.java` | -| 头程装柜完成 V1 | `FirstLegLoadingPlanCompletedV1.java`;国内生产者见 `../firstleg/service/LoadingCompletedOutboxService.java` | +| 头程装柜完成 V1 | `FirstLegLoadingPlanCompletedV1.java`;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java` | +| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` | | 契约说明 | `package-info.java` | -| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java` | +| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`OverseasContainerUnloadedV1Test.java` | ## 修改与验证 - 所有字段与事件类型都视为跨仓公共契约;当前开发阶段不保留旧事件字段、旧版本或兼容解析,目标契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。 - 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。 - MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。 +- `OVERSEAS_CONTAINER_UNLOADED` 固定从 `OVERSEAS` 发往 `CN`,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间由国内头程按照片上传时间加两天计算,实际报空时间允许后续补充。 - 从父目录运行 `mvn -pl common-event-contract -am test`。 diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java index abf2ce607..6841c9815 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java @@ -2,8 +2,9 @@ package com.baiyue.common.event; import java.time.Instant; -/** 海外仓卸柜完成事实;预计报空时间由头程按照片上传时间加两天计算。 */ +/** 海外仓卸柜完成事实;国内订舱 ID 是稳定聚合标识,containerNo 仅用于核对物理柜号。 */ public record OverseasContainerUnloadedV1( + String sourceContainerBookingId, String containerNo, Instant unloadingCompletedAt, Instant unloadingPhotoUploadedAt, diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java index dc847949b..70f843856 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java @@ -13,12 +13,12 @@ class OverseasContainerUnloadedV1Test { @Test void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception { - var payload = new OverseasContainerUnloadedV1("MSCU1234567", + var payload = new OverseasContainerUnloadedV1("1001", "MSCU1234567", Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null); String json = mapper.writeValueAsString(payload); assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload); - assertThat(json).contains("MSCU1234567", "unloadingPhotoUploadedAt"); + assertThat(json).contains("sourceContainerBookingId", "1001", "MSCU1234567", "unloadingPhotoUploadedAt"); } } diff --git a/config/nacos/cn/event-bridge-service.yml b/config/nacos/cn/event-bridge-service.yml index a5fbba087..baa031c2d 100644 --- a/config/nacos/cn/event-bridge-service.yml +++ b/config/nacos/cn/event-bridge-service.yml @@ -8,3 +8,5 @@ baiyue: region: ${REGION_CODE:CN} deployment-cell: ${DEPLOYMENT_CELL:prod-cn} data-residency: ${DATA_RESIDENCY:CN} + event-dispatcher: + consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events} diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/cn/firstleg-service.yml index dd326ed80..c620909f7 100644 --- a/config/nacos/cn/firstleg-service.yml +++ b/config/nacos/cn/firstleg-service.yml @@ -7,6 +7,13 @@ spring: baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} baiyue.clients.order.url: ${ORDER_SERVICE_URL:} baiyue.clients.file.url: ${FILE_SERVICE_URL:} +baiyue: + lastleg: + event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL} + outbox-relay: + fixed-delay-ms: ${LASTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000} + lease-seconds: ${LASTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} + retry-seconds: ${LASTLEG_OUTBOX_RELAY_RETRY_SECONDS:30} firstleg: inbound: allow-rejected-measurement: false diff --git a/config/nacos/overseas/event-bridge-service.yml b/config/nacos/overseas/event-bridge-service.yml index 610138e21..27c492a4b 100644 --- a/config/nacos/overseas/event-bridge-service.yml +++ b/config/nacos/overseas/event-bridge-service.yml @@ -9,7 +9,7 @@ baiyue: deployment-cell: ${DEPLOYMENT_CELL:prod-overseas} data-residency: ${DATA_RESIDENCY:OVERSEAS} event-dispatcher: - consumer-url: ${LASTLEG_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events} + consumer-url: ${EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} diff --git a/config/nacos/overseas/lastleg-service.yml b/config/nacos/overseas/lastleg-service.yml index 1b88f047a..5cdbb6d01 100644 --- a/config/nacos/overseas/lastleg-service.yml +++ b/config/nacos/overseas/lastleg-service.yml @@ -4,3 +4,16 @@ spring: username: ${LASTLEG_DB_USERNAME} password: ${LASTLEG_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver +baiyue: + clients: + domestic-warehouse: + url: ${DOMESTIC_WAREHOUSE_URL} + cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} + warehouse: + base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://warehouse-overseas-service:9305} + firstleg: + event-bridge-url: ${FIRSTLEG_EVENT_BRIDGE_URL} + outbox-relay: + fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000} + lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} + retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30} diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml new file mode 100644 index 000000000..c7cc50e2f --- /dev/null +++ b/deploy/docker-compose.overseas.yml @@ -0,0 +1,97 @@ +name: firstleg-${DEPLOY_ENV:-test}-overseas + +x-overseas-common: &overseas-common + restart: unless-stopped + networks: [firstleg-overseas-network, infra-network] + extra_hosts: + - "host.docker.internal:host-gateway" + environment: &overseas-environment + SPRING_PROFILES_ACTIVE: overseas + NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR} + NACOS_USERNAME: ${NACOS_USERNAME} + NACOS_PASSWORD: ${NACOS_PASSWORD} + NACOS_GROUP: ${NACOS_GROUP:-BAIYUE} + NACOS_NAMESPACE: ${OVERSEAS_NACOS_NAMESPACE:-prod-overseas} + REDIS_HOST: ${OVERSEAS_REDIS_HOST:-host.docker.internal} + REDIS_PORT: ${OVERSEAS_REDIS_PORT:-6379} + REDIS_PASSWORD: ${OVERSEAS_REDIS_PASSWORD:-} + REDIS_DATABASE: ${OVERSEAS_REDIS_DATABASE:-0} + INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN} + JWT_ISSUER: ${JWT_ISSUER} + JWT_AUDIENCE: ${OVERSEAS_JWT_AUDIENCE:-lastleg-api} + JWT_JWK_SET_URI: ${JWT_JWK_SET_URI} + REGION_CODE: OVERSEAS + DEPLOYMENT_CELL: ${OVERSEAS_DEPLOYMENT_CELL:-test-overseas} + DATA_RESIDENCY: OVERSEAS + SPRING_SQL_INIT_MODE: never + LOG_PATH: /app/logs + TZ: Asia/Shanghai + logging: + driver: json-file + options: + max-size: 100m + max-file: "5" + +services: + gateway-overseas: + <<: *overseas-common + image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG} + container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas + ports: ["${GATEWAY_OVERSEAS_PORT:-9300}:9300"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway-overseas:/app/logs"] + environment: + <<: *overseas-environment + SERVER_PORT: 9300 + SWAGGER_ENABLED: ${SWAGGER_ENABLED:-false} + CORS_ALLOWED_ORIGINS: ${OVERSEAS_CORS_ALLOWED_ORIGINS:-} + CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false} + + lastleg-service: + <<: *overseas-common + image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG} + container_name: firstleg-${DEPLOY_ENV:-test}-lastleg + ports: ["${LASTLEG_PORT:-9303}:9303"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/lastleg-service:/app/logs"] + environment: + <<: *overseas-environment + SERVER_PORT: 9303 + LASTLEG_DB_URL: ${LASTLEG_DB_URL} + LASTLEG_DB_USERNAME: ${LASTLEG_DB_USERNAME} + LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD} + FIRSTLEG_EVENT_BRIDGE_URL: ${FIRSTLEG_EVENT_BRIDGE_URL} + DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} + WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305} + + warehouse-overseas-service: + <<: *overseas-common + image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG} + container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas + ports: ["${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse-overseas-service:/app/logs"] + environment: + <<: *overseas-environment + SERVER_PORT: 9305 + WAREHOUSE_DB_URL: ${WAREHOUSE_OVERSEAS_DB_URL} + WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME} + WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD} + + event-bridge-service: + <<: *overseas-common + image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} + container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas + ports: ["${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service-overseas:/app/logs"] + environment: + <<: *overseas-environment + SERVER_PORT: 9312 + EVENT_BRIDGE_DB_URL: ${EVENT_BRIDGE_OVERSEAS_DB_URL} + EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_OVERSEAS_DB_USERNAME} + EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_OVERSEAS_DB_PASSWORD} + EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/events} + +networks: + firstleg-overseas-network: + name: firstleg-${DEPLOY_ENV:-test}-overseas-network + infra-network: + external: true + name: ${INFRA_NETWORK:-firstleg-test-infra-network} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index aac92b985..60790fe27 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -86,6 +86,7 @@ services: FIRSTLEG_DB_URL: ${FIRSTLEG_DB_URL} FIRSTLEG_DB_USERNAME: ${FIRSTLEG_DB_USERNAME} FIRSTLEG_DB_PASSWORD: ${FIRSTLEG_DB_PASSWORD} + LASTLEG_EVENT_BRIDGE_URL: ${LASTLEG_EVENT_BRIDGE_URL} finance: <<: *common @@ -185,14 +186,16 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge - ports: ["${EVENT_BRIDGE_PORT:-9300}:9300"] + ports: ["${EVENT_BRIDGE_PORT:-9312}:9312"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service:/app/logs"] environment: <<: *common-environment - SERVER_PORT: 9300 + SERVER_PORT: 9312 EVENT_BRIDGE_DB_URL: ${EVENT_BRIDGE_DB_URL} EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_DB_USERNAME} EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_DB_PASSWORD} + EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events} + SPRING_SQL_INIT_MODE: never audit-service: <<: *common diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index e6eb3fa1e..420d2e9d7 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -30,12 +30,10 @@ case "$SERVICE" in ;; esac -for file in "$ENV_FILE" "$COMPOSE_FILE"; do - if [ ! -f "$file" ]; then - echo "必需文件不存在: $file" >&2 - exit 2 - fi -done +if [ ! -f "$ENV_FILE" ]; then + echo "必需文件不存在: $ENV_FILE" >&2 + exit 2 +fi env_file_has_key() { grep -q "^$1=" "$ENV_FILE" @@ -45,6 +43,37 @@ env_file_value() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r' } +case "$SERVICE" in + gateway-overseas|lastleg-service|warehouse-overseas-service) + COMPOSE_FILE="deploy/docker-compose.overseas.yml" + PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas" + ;; + event-bridge-service) + if ! env_file_has_key REGION_CODE; then + echo "部署 event-bridge-service 时,环境文件必须显式包含 REGION_CODE=CN 或 REGION_CODE=OVERSEAS" >&2 + exit 2 + fi + case "$(env_file_value REGION_CODE)" in + CN) ;; + OVERSEAS) + COMPOSE_FILE="deploy/docker-compose.overseas.yml" + PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas" + ;; + *) + echo "部署 event-bridge-service 时,REGION_CODE 只能为 CN 或 OVERSEAS" >&2 + exit 2 + ;; + esac + ;; +esac + +for file in "$ENV_FILE" "$COMPOSE_FILE"; do + if [ ! -f "$file" ]; then + echo "必需文件不存在: $file" >&2 + exit 2 + fi +done + if [ "$SERVICE" = "finance" ]; then for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then @@ -54,6 +83,56 @@ if [ "$SERVICE" = "finance" ]; then done fi +case "$SERVICE" in + gateway-overseas|lastleg-service|warehouse-overseas-service) + for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署海外服务时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done + ;; +esac + +if [ "$SERVICE" = "firstleg" ]; then + if ! env_file_has_key LASTLEG_EVENT_BRIDGE_URL || [ -z "$(env_file_value LASTLEG_EVENT_BRIDGE_URL)" ]; then + echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2 + exit 2 + fi +fi + +if [ "$SERVICE" = "lastleg-service" ]; then + for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done +fi + +if [ "$SERVICE" = "warehouse-overseas-service" ]; then + for key in WAREHOUSE_OVERSEAS_DB_URL WAREHOUSE_OVERSEAS_DB_USERNAME WAREHOUSE_OVERSEAS_DB_PASSWORD; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 warehouse-overseas-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done +fi + +if [ "$SERVICE" = "event-bridge-service" ]; then + if [ "$PROJECT_NAME" = "firstleg-$DEPLOY_ENV-overseas" ]; then + EVENT_BRIDGE_KEYS="EVENT_BRIDGE_OVERSEAS_DB_URL EVENT_BRIDGE_OVERSEAS_DB_USERNAME EVENT_BRIDGE_OVERSEAS_DB_PASSWORD EVENT_CONSUMER_URL" + else + EVENT_BRIDGE_KEYS="EVENT_BRIDGE_DB_URL EVENT_BRIDGE_DB_USERNAME EVENT_BRIDGE_DB_PASSWORD EVENT_CONSUMER_URL" + fi + for key in $EVENT_BRIDGE_KEYS; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 event-bridge-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done +fi + if [ "$SERVICE" = "file" ]; then if ! env_file_has_key ROCKETMQ_NAME_SERVER || [ -z "$(env_file_value ROCKETMQ_NAME_SERVER)" ]; then echo "部署 file 服务时,环境文件必须包含非空 ROCKETMQ_NAME_SERVER" >&2 @@ -149,24 +228,25 @@ REMOTE_PREPARE echo "传输镜像到 $SSH_TARGET: $IMAGE" docker save "$IMAGE" | gzip -1 | run_ssh 'gzip -dc | docker load' -run_ssh sh -s -- "$REMOTE_DIR" "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" <<'REMOTE_DEPLOY' +run_ssh sh -s -- "$REMOTE_DIR" "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" "$PROJECT_NAME" <<'REMOTE_DEPLOY' set -eu remote_dir="$1" deploy_env="$2" image_repository="$3" image_tag="$4" service="$5" +project_name="$6" cd "$remote_dir" export DEPLOY_ENV="$deploy_env" export IMAGE_REPOSITORY="$image_repository" export IMAGE_TAG="$image_tag" docker compose \ - --project-name "firstleg-$deploy_env" \ + --project-name "$project_name" \ --env-file .env \ -f docker-compose.yml \ up -d --remove-orphans --pull never "$service" docker compose \ - --project-name "firstleg-$deploy_env" \ + --project-name "$project_name" \ --env-file .env \ -f docker-compose.yml \ ps "$service" diff --git a/deploy/scripts/deploy-development-service.sh b/deploy/scripts/deploy-development-service.sh index 459337f8a..b1034e2b9 100644 --- a/deploy/scripts/deploy-development-service.sh +++ b/deploy/scripts/deploy-development-service.sh @@ -15,7 +15,8 @@ SSH_TARGET="${DEVELOPMENT_SSH_TARGET:-deploy@172.31.198.8}" SSH_KEY="${DEVELOPMENT_SSH_KEY:-/var/jenkins_home/.ssh/dev_deploy}" SSH_KNOWN_HOSTS="${DEVELOPMENT_SSH_KNOWN_HOSTS:-/var/jenkins_home/.ssh/known_hosts}" REMOTE_DIR="${DEVELOPMENT_DEPLOY_DIR:-/opt/firstleg/application}" -COMPOSE_FILE="deploy/docker-compose.yml" +COMPOSE_FILE="${DEPLOY_COMPOSE_FILE:-deploy/docker-compose.yml}" +PROJECT_NAME="${DEPLOY_PROJECT_NAME:-firstleg-development}" if [ ! -f "$SSH_KEY" ]; then echo "开发环境 SSH 私钥不存在: $SSH_KEY" >&2 @@ -65,19 +66,19 @@ if [ "${DEVELOPMENT_DEPLOY_LOCAL:-false}" = "true" ]; then export IMAGE_REPOSITORY IMAGE_TAG docker compose \ - --project-name firstleg-development \ + --project-name "$PROJECT_NAME" \ --env-file .env \ -f docker-compose.yml \ pull $SERVICES docker compose \ - --project-name firstleg-development \ + --project-name "$PROJECT_NAME" \ --env-file .env \ -f docker-compose.yml \ up -d --remove-orphans --pull never $SERVICES docker compose \ - --project-name firstleg-development \ + --project-name "$PROJECT_NAME" \ --env-file .env \ -f docker-compose.yml \ ps $SERVICES @@ -129,12 +130,13 @@ for service in $SERVICES; do docker save "$image" | gzip -1 | run_ssh 'gzip -dc | docker load' done -run_ssh sh -s -- "$REMOTE_DIR" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" <<'REMOTE_DEPLOY' +run_ssh sh -s -- "$REMOTE_DIR" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$PROJECT_NAME" <<'REMOTE_DEPLOY' set -eu remote_dir="$1" image_repository="$2" image_tag="$3" service_csv="$4" +project_name="$5" services=$(printf '%s' "$service_csv" | tr ',' ' ') cd "$remote_dir" @@ -143,13 +145,13 @@ export IMAGE_REPOSITORY="$image_repository" export IMAGE_TAG="$image_tag" docker compose \ - --project-name firstleg-development \ + --project-name "$project_name" \ --env-file .env \ -f docker-compose.yml \ up -d --remove-orphans --pull never $services docker compose \ - --project-name firstleg-development \ + --project-name "$project_name" \ --env-file .env \ -f docker-compose.yml \ ps $services diff --git a/deploy/scripts/deploy-services.sh b/deploy/scripts/deploy-services.sh index 41a2c0b9b..e420ee93a 100644 --- a/deploy/scripts/deploy-services.sh +++ b/deploy/scripts/deploy-services.sh @@ -19,6 +19,15 @@ fi SERVICES=$(printf '%s' "$SERVICE_CSV" | tr ',' ' ') +for service in $SERVICES; do + case "$service" in + *[!A-Za-z0-9_-]*|'') + echo "非法服务名: $service" >&2 + exit 2 + ;; + esac +done + env_file_has_key() { grep -q "^$1=" "$ENV_FILE" } @@ -27,6 +36,46 @@ env_file_value() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r' } +DEPLOY_REGION=CN +for service in $SERVICES; do + case "$service" in + gateway-overseas|lastleg-service|warehouse-overseas-service) DEPLOY_REGION=OVERSEAS ;; + event-bridge-service) + if ! env_file_has_key REGION_CODE; then + echo "部署 event-bridge-service 时,环境文件必须显式包含 REGION_CODE=CN 或 REGION_CODE=OVERSEAS" >&2 + exit 2 + fi + case "$(env_file_value REGION_CODE)" in + CN) ;; + OVERSEAS) DEPLOY_REGION=OVERSEAS ;; + *) echo "部署 event-bridge-service 时,REGION_CODE 只能为 CN 或 OVERSEAS" >&2; exit 2 ;; + esac + ;; + esac +done +if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then + for service in $SERVICES; do + case "$service" in + gateway-overseas|lastleg-service|warehouse-overseas-service|event-bridge-service) ;; + *) echo "一次部署不得混合国内与海外服务: $SERVICE_CSV" >&2; exit 2 ;; + esac + done + COMPOSE_FILE="deploy/docker-compose.overseas.yml" + PROJECT_NAME="firstleg-${DEPLOY_ENV}-overseas" +else + COMPOSE_FILE="deploy/docker-compose.yml" + PROJECT_NAME="firstleg-${DEPLOY_ENV}" +fi + +if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then + for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署海外服务时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done +fi + case " $SERVICES " in *" finance "*) for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do @@ -38,6 +87,53 @@ case " $SERVICES " in ;; esac +case " $SERVICES " in + *" firstleg "*) + if ! env_file_has_key LASTLEG_EVENT_BRIDGE_URL || [ -z "$(env_file_value LASTLEG_EVENT_BRIDGE_URL)" ]; then + echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2 + exit 2 + fi + ;; +esac + +case " $SERVICES " in + *" lastleg-service "*) + for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done + ;; +esac + +case " $SERVICES " in + *" warehouse-overseas-service "*) + for key in WAREHOUSE_OVERSEAS_DB_URL WAREHOUSE_OVERSEAS_DB_USERNAME WAREHOUSE_OVERSEAS_DB_PASSWORD; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 warehouse-overseas-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done + ;; +esac + +case " $SERVICES " in + *" event-bridge-service "*) + if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then + EVENT_BRIDGE_KEYS="EVENT_BRIDGE_OVERSEAS_DB_URL EVENT_BRIDGE_OVERSEAS_DB_USERNAME EVENT_BRIDGE_OVERSEAS_DB_PASSWORD EVENT_CONSUMER_URL" + else + EVENT_BRIDGE_KEYS="EVENT_BRIDGE_DB_URL EVENT_BRIDGE_DB_USERNAME EVENT_BRIDGE_DB_PASSWORD EVENT_CONSUMER_URL" + fi + for key in $EVENT_BRIDGE_KEYS; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 event-bridge-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done + ;; +esac + case " $SERVICES " in *" file "*) if ! env_file_has_key ROCKETMQ_NAME_SERVER || [ -z "$(env_file_value ROCKETMQ_NAME_SERVER)" ]; then @@ -75,24 +171,24 @@ esac export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG if [ "$DEPLOY_ENV" = "development" ]; then - exec sh deploy/scripts/deploy-development-service.sh \ + DEPLOY_COMPOSE_FILE="$COMPOSE_FILE" DEPLOY_PROJECT_NAME="$PROJECT_NAME" exec sh deploy/scripts/deploy-development-service.sh \ "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$ENV_FILE" fi docker compose \ - --project-name "firstleg-${DEPLOY_ENV}" \ + --project-name "$PROJECT_NAME" \ --env-file "$ENV_FILE" \ - -f deploy/docker-compose.yml \ + -f "$COMPOSE_FILE" \ pull $SERVICES docker compose \ - --project-name "firstleg-${DEPLOY_ENV}" \ + --project-name "$PROJECT_NAME" \ --env-file "$ENV_FILE" \ - -f deploy/docker-compose.yml \ + -f "$COMPOSE_FILE" \ up -d --remove-orphans --pull never $SERVICES docker compose \ - --project-name "firstleg-${DEPLOY_ENV}" \ + --project-name "$PROJECT_NAME" \ --env-file "$ENV_FILE" \ - -f deploy/docker-compose.yml \ + -f "$COMPOSE_FILE" \ ps $SERVICES diff --git a/deploy/service-map.yml b/deploy/service-map.yml index 7b8008371..e22eec804 100644 --- a/deploy/service-map.yml +++ b/deploy/service-map.yml @@ -3,6 +3,11 @@ services: artifactId: gateway jar: gateway/target/gateway-1.0.0-SNAPSHOT.jar port: 9000 + gateway-overseas: + artifactId: gateway-overseas + jar: gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar + port: 9300 + regions: [OVERSEAS] auth: artifactId: auth-service jar: auth/target/auth-service-1.0.0-SNAPSHOT.jar @@ -35,10 +40,21 @@ services: artifactId: integration-service jar: integration-service/target/integration-service-1.0.0-SNAPSHOT.jar port: 9210 + lastleg-service: + artifactId: lastleg-service + jar: lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar + port: 9303 + regions: [OVERSEAS] + warehouse-overseas-service: + artifactId: warehouse-overseas-service + jar: warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar + port: 9305 + regions: [OVERSEAS] event-bridge-service: artifactId: event-bridge-service jar: event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar - port: 9300 + port: 9312 + regions: [CN, OVERSEAS] audit-service: artifactId: audit-service jar: audit-service/target/audit-service-1.0.0-SNAPSHOT.jar diff --git a/deploy/sql/README.md b/deploy/sql/README.md index f5caabb04..486bd1d5b 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -2,7 +2,9 @@ ## 唯一数据库初始化与发布入口 -- 固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 +- 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 +- 海外区域固定入口:`海外区域统一增量-MySQL8.sql`;当前包含海外仓报价结构,后续海外主数据继续追加到同一入口,不按日期拆分。 +- 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。 - 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 - 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。 @@ -215,3 +217,9 @@ SELECT - Warehouse 新增 `wh_overseas_storage_fee_rule` 和 `wh_overseas_storage_fee_tier`,包含客户唯一索引、分页索引、阶梯起始天唯一索引和规则明细查询索引。 - IAM 新增 `platform:overseas-storage-fee:list/create/edit/delete` 四项权限,默认授予平台系统管理员。 - Warehouse Service 启动前必须先执行固定入口;末尾只有两张表、五个索引和四项权限均存在才返回 `PASS`。 + +## 四期海外权限目录 + +- 固定入口已同步 `iam/src/main/resources/db/lastleg-permissions.sql` 的 `platform:overseas-*` 权限,并默认授予平台系统管理员。 +- 旧 `platform:lastleg:*` 权限会撤销角色关联并停用;不保留旧码兼容映射。 +- 本批不创建箱号、产品库、海外出库、退货出库或海外派送权限。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a4849b700..841aa7d1a 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -9190,6 +9190,8 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱状态历史不可删除'; DELIMITER ; USE baiyue_iam; +DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102141,5102143,5102144,5102145); +DELETE FROM iam_permission WHERE id IN (5102141,5102143,5102144,5102145); DROP PROCEDURE IF EXISTS assert_ocean_booking_permission_ids_available; DELIMITER $$ CREATE PROCEDURE assert_ocean_booking_permission_ids_available() @@ -9197,23 +9199,14 @@ BEGIN IF EXISTS ( SELECT 1 FROM iam_permission WHERE (id = 5102140 AND permission_code <> 'platform:firstleg:ocean-booking:list') - OR (id = 5102141 AND permission_code NOT IN ( - 'platform:firstleg:ocean-booking:list', - 'platform:firstleg:ocean-booking:view')) OR (id = 5102142 AND permission_code <> 'platform:firstleg:ocean-booking:create') - OR (id = 5102143 AND permission_code <> 'platform:firstleg:ocean-booking:edit') - OR (id = 5102144 AND permission_code <> 'platform:firstleg:ocean-booking:submit') - OR (id = 5102145 AND permission_code <> 'platform:firstleg:ocean-booking:confirm') OR (id = 5102146 AND permission_code <> 'platform:firstleg:ocean-booking:release') - OR (id = 5102147 AND permission_code NOT IN ( - 'platform:firstleg:ocean-booking:cancel', - 'platform:firstleg:ocean-booking:edit')) - OR (id = 5102148 AND permission_code NOT IN ( - 'platform:firstleg:ocean-booking:change', - 'platform:firstleg:ocean-booking:delete')) + OR (id = 5102147 AND permission_code <> 'platform:firstleg:ocean-booking:edit') + OR (id = 5102148 AND permission_code <> 'platform:firstleg:ocean-booking:delete') OR (id = 5102149 AND permission_code <> 'platform:firstleg:ocean-booking:allocate') OR (permission_code LIKE 'platform:firstleg:ocean-booking:%' - AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102140 AND 5102149)) + AND (tenant_id <> 0 OR app_code <> 'PLATFORM' + OR id NOT IN (5102140,5102142,5102146,5102147,5102148,5102149))) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '三期订舱权限迁移失败:5102140-5102149 ID 或权限码被其他语义占用'; @@ -9223,13 +9216,6 @@ DELIMITER ; CALL assert_ocean_booking_permission_ids_available(); DROP PROCEDURE IF EXISTS assert_ocean_booking_permission_ids_available; -UPDATE iam_permission - SET permission_code='platform:firstleg:ocean-booking:view',status='INACTIVE',deleted=1 - WHERE id=5102141 AND permission_code='platform:firstleg:ocean-booking:list'; - -DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102143,5102144,5102145); -DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145); - INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name, route_path,http_method,sort_no,status,built_in,deleted) @@ -9249,10 +9235,6 @@ SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.permission_code LIKE 'platform:firstleg:ocean-booking:%' WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR'); -DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id -WHERE p.id=5102141; -UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102141; - USE baiyue_firstleg; @@ -9338,7 +9320,7 @@ BEGIN WHERE (id = 5102150 AND permission_code <> 'platform:firstleg:truck-appointment:list') OR (id = 5102151 AND permission_code NOT IN ( 'platform:firstleg:truck-appointment:list', - 'platform:firstleg:truck-appointment:view')) + CONCAT('platform:firstleg:truck-appointment:', 'view'))) OR (id = 5102152 AND permission_code <> 'platform:firstleg:truck-appointment:create') OR (id = 5102153 AND permission_code <> 'platform:firstleg:truck-appointment:edit') OR (id = 5102154 AND permission_code <> 'platform:firstleg:truck-appointment:operate') @@ -9353,10 +9335,6 @@ DELIMITER ; CALL assert_truck_appointment_permission_ids_available(); DROP PROCEDURE IF EXISTS assert_truck_appointment_permission_ids_available; -UPDATE iam_permission - SET permission_code='platform:firstleg:truck-appointment:view',status='INACTIVE',deleted=1 - WHERE id=5102151 AND permission_code='platform:firstleg:truck-appointment:list'; - INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name, route_path,http_method,sort_no,status,built_in,deleted) @@ -9377,7 +9355,7 @@ WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id WHERE p.id=5102151; -UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102151; +DELETE FROM iam_permission WHERE id=5102151; USE baiyue_firstleg; @@ -10267,10 +10245,18 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码', official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照', access_mode VARCHAR(32) NOT NULL COMMENT '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面', - status VARCHAR(24) NOT NULL COMMENT '任务状态:READY可查询、MANUAL_REQUIRED需人工处理、SUCCESS已取得有效结果', + status VARCHAR(24) NOT NULL COMMENT '任务状态:READY、MANUAL_REQUIRED、ARRIVED、PICKUP_READY、STOPPED', + trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL', + latest_standard_node VARCHAR(32) NULL COMMENT '最近标准船节点', + latest_node_observed_at DATETIME(3) NULL COMMENT '最近标准节点观测时间', + stopped_at DATETIME(3) NULL COMMENT '任务停止时间', + stop_reason VARCHAR(500) NULL COMMENT '任务停止原因', + lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者', + lease_until DATETIME(3) NULL COMMENT '当前调度租约过期时间', + consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败次数,用于指数退避', window_started_at DATETIME(3) NOT NULL COMMENT '当前三天查询窗口起始时间', attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前查询窗口已执行次数;最多3次', - next_allowed_at DATETIME(3) NOT NULL COMMENT '频控规则允许的下次查询时间', + next_allowed_at DATETIME(3) NULL COMMENT '频控规则允许的下次查询时间;停止后为空', last_attempt_at DATETIME(3) NULL COMMENT '最近一次查询尝试时间', last_success_at DATETIME(3) NULL COMMENT '最近一次取得有效结果时间', source_updated_at DATETIME(3) NULL COMMENT '船公司官网展示的结果更新时间', @@ -10291,7 +10277,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( PRIMARY KEY (id), UNIQUE KEY uk_carrier_schedule_query_no (query_no), UNIQUE KEY uk_carrier_schedule_query_scope - (customer_id,ocean_booking_id,container_scope_id,carrier_code,tracking_type,tracking_no), + (customer_id,ocean_booking_id,container_scope_id), KEY idx_carrier_schedule_query_due (status,next_allowed_at,id), KEY idx_carrier_schedule_query_customer (customer_id,updated_at,id), KEY idx_carrier_schedule_query_expiry (expires_at,id), @@ -10306,7 +10292,10 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( CONSTRAINT chk_carrier_schedule_query_access CHECK (access_mode IN ('PUBLIC_FORM','HCAPTCHA','DYNAMIC_IFRAME')), CONSTRAINT chk_carrier_schedule_query_status CHECK - (status IN ('READY','MANUAL_REQUIRED','SUCCESS')), + (status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY','STOPPED')), + CONSTRAINT chk_carrier_schedule_query_node CHECK + (latest_standard_node IS NULL OR latest_standard_node IN + ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')), CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3) ) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控'; @@ -10349,9 +10338,35 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history ( PRIMARY KEY (id), KEY idx_carrier_schedule_history_query (query_id,created_at,id), CONSTRAINT fk_carrier_schedule_history_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), - CONSTRAINT chk_carrier_schedule_history_action CHECK (action IN ('CREATE','ATTEMPT','MANUAL_RESULT')) + CONSTRAINT chk_carrier_schedule_history_action CHECK + (action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','NODE_OBSERVED','PROVIDER_QUERY','STOP')) ) ENGINE=InnoDB COMMENT='船司查询任务不可变历史'; +CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( + id BIGINT UNSIGNED NOT NULL COMMENT '标准船节点事实ID', + query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID', + event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', + provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码', + provider_node_code VARCHAR(64) NOT NULL COMMENT '供应商原始节点编码', + standard_node VARCHAR(32) NOT NULL COMMENT '标准船节点', + node_sequence INT UNSIGNED NOT NULL COMMENT '标准节点单调序号', + observed_at DATETIME(3) NOT NULL COMMENT '供应商观测时间', + estimated_at DATETIME(3) NULL COMMENT '供应商预计发生时间', + actual_at DATETIME(3) NULL COMMENT '供应商确认实际发生时间', + location_code VARCHAR(32) NULL COMMENT '节点地点编码', + location_name VARCHAR(128) NULL COMMENT '节点地点名称', + source_reference VARCHAR(500) NULL COMMENT '可审计来源引用', + raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', + PRIMARY KEY (id), + UNIQUE KEY uk_carrier_schedule_node_event (query_id,event_key), + KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id), + CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), + CONSTRAINT chk_carrier_schedule_node_standard CHECK + (standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')), + CONSTRAINT chk_carrier_schedule_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60)) +) ENGINE=InnoDB COMMENT='供应商标准船节点不可变事实'; + CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation ( id BIGINT UNSIGNED NOT NULL COMMENT '船司查询写操作幂等记录ID', query_id BIGINT UNSIGNED NULL COMMENT '关联查询任务ID;创建任务落库前允许为空', @@ -10392,6 +10407,8 @@ DROP TRIGGER IF EXISTS trg_carrier_schedule_attempt_before_update; DROP TRIGGER IF EXISTS trg_carrier_schedule_attempt_before_delete; DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_update; DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_delete; +DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_update; +DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_delete; DELIMITER $$ CREATE TRIGGER trg_carrier_schedule_attempt_before_update BEFORE UPDATE ON fl_carrier_schedule_query_attempt FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询尝试不可修改'; END$$ @@ -10401,15 +10418,21 @@ CREATE TRIGGER trg_carrier_schedule_history_before_update BEFORE UPDATE ON fl_ca FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史不可修改'; END$$ CREATE TRIGGER trg_carrier_schedule_history_before_delete BEFORE DELETE ON fl_carrier_schedule_query_history FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史不可删除'; END$$ +CREATE TRIGGER trg_carrier_schedule_node_before_update BEFORE UPDATE ON fl_carrier_schedule_node_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可修改'; END$$ +CREATE TRIGGER trg_carrier_schedule_node_before_delete BEFORE DELETE ON fl_carrier_schedule_node_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可删除'; END$$ DELIMITER ; USE baiyue_iam; DELETE role_permission FROM iam_role_permission role_permission JOIN iam_permission permission ON permission.id=role_permission.permission_id WHERE permission.tenant_id=0 - AND permission.permission_code='platform:firstleg:port-schedule-query:view'; + AND permission.permission_code IN ('platform:firstleg:port-schedule-query:view', + 'platform:firstleg:port-schedule-query:attempt'); DELETE FROM iam_permission -WHERE tenant_id=0 AND permission_code='platform:firstleg:port-schedule-query:view'; +WHERE tenant_id=0 AND permission_code IN ('platform:firstleg:port-schedule-query:view', + 'platform:firstleg:port-schedule-query:attempt'); DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_permission_ids; DELIMITER $$ @@ -10417,16 +10440,15 @@ CREATE PROCEDURE assert_carrier_schedule_query_permission_ids() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id IN (5102184,5102186,5102187,5102188) + WHERE (id IN (5102184,5102186,5102188) AND permission_code <> CASE id WHEN 5102184 THEN 'platform:firstleg:port-schedule-query:list' WHEN 5102186 THEN 'platform:firstleg:port-schedule-query:create' - WHEN 5102187 THEN 'platform:firstleg:port-schedule-query:attempt' WHEN 5102188 THEN 'platform:firstleg:port-schedule-query:manual' END) - OR id=5102185 + OR id IN (5102185,5102187) OR (permission_code LIKE 'platform:firstleg:port-schedule-query:%' - AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT IN (5102184,5102186,5102187,5102188))) + AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT IN (5102184,5102186,5102188))) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='三期船司查询权限迁移失败:5102184-5102188 ID或权限码被占用'; @@ -10442,7 +10464,6 @@ INSERT INTO iam_permission VALUES (5102184,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:list','查询船司任务',NULL,'GET',40,'ACTIVE',1,0), (5102186,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:create','创建船司任务',NULL,'POST',60,'ACTIVE',1,0), - (5102187,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:attempt','执行船司查询',NULL,'POST',70,'ACTIVE',1,0), (5102188,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:manual','录入人工结果',NULL,'POST',80,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type), permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id), @@ -10463,12 +10484,13 @@ BEGIN IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_firstleg' AND table_name IN ('fl_carrier_schedule_query','fl_carrier_schedule_query_attempt', - 'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation')) <> 4 THEN + 'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation', + 'fl_carrier_schedule_node_fact')) <> 5 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务表未完整创建'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission - WHERE tenant_id=0 AND id BETWEEN 5102184 AND 5102188 - AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 4 THEN + WHERE tenant_id=0 AND id IN (5102184,5102186,5102188) + AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务权限未完整创建'; END IF; IF (SELECT COUNT(*) @@ -10477,7 +10499,7 @@ BEGIN JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') AND permission.tenant_id=0 - AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 8 THEN + AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 6 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务角色授权未完整创建'; END IF; END$$ @@ -11727,6 +11749,7 @@ CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query', '六家 CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', '船司官网查询尝试不可变审计'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', '船司查询任务不可变历史'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', '船司查询写操作幂等与请求摘要'); +CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', '供应商标准船节点不可变事实'); CALL queue_table_comment('baiyue_firstleg', 'fl_consumed_event', '头程消息消费幂等表:记录已处理事件并按事件ID去重,防止RocketMQ重复投递造成重复建单;不是消息发布表'); CALL queue_table_comment('baiyue_firstleg', 'fl_container_booking', '平台货柜预订资料;预装状态唯一来源为装柜计划'); CALL queue_table_comment('baiyue_firstleg', 'fl_container_document', '平台货柜清关、DO与PU文件关联'); @@ -12331,7 +12354,15 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'query CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'remark', '平台内部备注'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'result_port_schedule_id', '最近有效结果对应的船期记录ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'source_updated_at', '船公司官网展示的结果更新时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'status', '任务状态:READY可查询、MANUAL_REQUIRED需人工处理、SUCCESS已取得有效结果'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'status', '任务状态:READY、MANUAL_REQUIRED、ARRIVED、PICKUP_READY、STOPPED'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'trigger_source', '任务触发来源:OUTBOUND_CONFIRM或MANUAL'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'latest_standard_node', '最近标准船节点'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'latest_node_observed_at', '最近标准节点观测时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'stopped_at', '任务停止时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'stop_reason', '任务停止原因'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'lease_owner', '当前调度租约持有者'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'lease_until', '当前调度租约过期时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'consecutive_failures', '连续失败次数,用于指数退避'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'tracking_no', '提交至船公司官网的跟踪号码'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'tracking_type', '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'updated_at', '任务最后更新时间'); @@ -12375,6 +12406,21 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operatio CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', 'request_hash', '规范化请求内容SHA-256摘要;用于识别同键不同请求'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', 'result_json', '成功结果JSON快照;供幂等重放返回'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', 'status', '处理状态:PROCESSING处理中、SUCCESS成功'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'id', '标准船节点事实ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'query_id', '所属查询任务ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'event_key', '供应商编码与供应商事件ID组成的幂等键'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_code', '轨迹供应商编码'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_node_code', '供应商原始节点编码'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'standard_node', '标准船节点'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'node_sequence', '标准节点单调序号'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'observed_at', '供应商观测时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'estimated_at', '供应商预计发生时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'actual_at', '供应商确认实际发生时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'location_code', '节点地点编码'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'location_name', '节点地点名称'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'source_reference', '可审计来源引用'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'raw_payload_hash', '原始响应SHA-256;不保存敏感原文'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'created_at', '事实入库时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_consumed_event', 'consumed_at', '首次成功消费时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_consumed_event', 'event_id', '已消费领域事件ID,与生产端Outbox事件ID一致'); CALL queue_column_comment('baiyue_firstleg', 'fl_consumed_event', 'event_type', '已消费事件类型'); @@ -15701,6 +15747,81 @@ ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀'; ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法'; ++-- 四期海外权限统一目录(来源:iam/db/lastleg-permissions.sql) +-- 四期 Admin 海外权限只使用 platform:overseas-*;列表权限包含详情只读。 +DELETE rp FROM baiyue_iam.iam_role_permission rp +JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id +WHERE p.permission_code LIKE 'platform:lastleg:%'; +UPDATE baiyue_iam.iam_permission SET status='INACTIVE', deleted=1 +WHERE permission_code LIKE 'platform:lastleg:%'; + +INSERT INTO baiyue_iam.iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, built_in, deleted) +VALUES + (5107000,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',700,'ACTIVE',1,0), + (5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,0), + (5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,0), + (5107010,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inbound-order:list','海外入仓单','/overseas-warehouse/inbound-orders','GET',710,'ACTIVE',1,0), + (5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,0), + (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0), + (5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,0), + (5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,0), + (5107022,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:status','变更海外换标单状态',NULL,'PUT',722,'ACTIVE',1,0), + (5107030,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:pallet:list','海外托盘','/overseas-warehouse/pallets','GET',730,'ACTIVE',1,0), + (5107031,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:create','创建海外托盘',NULL,'POST',731,'ACTIVE',1,0), + (5107032,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:edit','编辑海外托盘',NULL,'PUT',732,'ACTIVE',1,0), + (5107033,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:delete','删除海外托盘',NULL,'DELETE',733,'ACTIVE',1,0), + (5107040,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inventory:list','海外库存','/overseas-warehouse/inventory','GET',740,'ACTIVE',1,0), + (5107041,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:export','导出海外库存',NULL,'GET',741,'ACTIVE',1,0), + (5107042,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:edit','编辑海外库存状态',NULL,'POST',742,'ACTIVE',1,0), + (5107043,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:adjust','调整海外库存',NULL,'POST',743,'ACTIVE',1,0), + (5107050,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:warehouse:list','海外仓资源','/overseas-warehouse/warehouses','GET',750,'ACTIVE',1,0), + (5107051,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:create','创建海外仓资源',NULL,'POST',751,'ACTIVE',1,0), + (5107052,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:edit','编辑海外仓资源',NULL,'PUT',752,'ACTIVE',1,0), + (5107053,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:status','变更海外仓状态',NULL,'PATCH',753,'ACTIVE',1,0), + (5107060,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:location:list','海外库位','/overseas-warehouse/locations','GET',760,'ACTIVE',1,0), + (5107061,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:create','创建海外库位',NULL,'POST',761,'ACTIVE',1,0), + (5107062,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:edit','编辑海外库位',NULL,'PUT',762,'ACTIVE',1,0), + (5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,0), + (5107100,0,'PLATFORM','MENU',0,'platform:overseas-business:purchase:list','海外采购订单','/overseas-business/purchases','GET',800,'ACTIVE',1,0), + (5107101,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:edit','编辑海外采购订单',NULL,'PATCH',801,'ACTIVE',1,0), + (5107102,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:review','审核海外采购订单',NULL,'POST',802,'ACTIVE',1,0), + (5107110,0,'PLATFORM','MENU',0,'platform:overseas-business:truck:list','本土卡车','/overseas-business/trucks','GET',810,'ACTIVE',1,0), + (5107111,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:create','创建本土卡车单',NULL,'POST',811,'ACTIVE',1,0), + (5107112,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:edit','编辑本土卡车单',NULL,'PUT',812,'ACTIVE',1,0), + (5107113,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:status','变更本土卡车状态',NULL,'PATCH',813,'ACTIVE',1,0), + (5107120,0,'PLATFORM','MENU',0,'platform:overseas-business:looseboard:list','本土散板','/overseas-business/looseboards','GET',820,'ACTIVE',1,0), + (5107121,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:create','创建本土散板单',NULL,'POST',821,'ACTIVE',1,0), + (5107122,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:edit','编辑本土散板单',NULL,'PUT',822,'ACTIVE',1,0), + (5107123,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:status','变更本土散板状态',NULL,'PATCH',823,'ACTIVE',1,0), + (5107200,0,'PLATFORM','MENU',0,'platform:overseas-master-data:quote:list','海外仓报价','/overseas-master-data/quotes','GET',900,'ACTIVE',1,0), + (5107201,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:create','创建海外仓报价',NULL,'POST',901,'ACTIVE',1,0), + (5107202,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:edit','编辑海外仓报价',NULL,'PUT',902,'ACTIVE',1,0), + (5107203,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:status','变更海外仓报价状态',NULL,'PATCH',903,'ACTIVE',1,0), + (5107204,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:export','导出海外仓报价',NULL,'GET',904,'ACTIVE',1,0), + (5107210,0,'PLATFORM','MENU',0,'platform:overseas-master-data:exchange-rate:list','月度汇率','/overseas-master-data/exchange-rates','GET',910,'ACTIVE',1,0), + (5107211,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:create','创建月度汇率',NULL,'POST',911,'ACTIVE',1,0), + (5107212,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:edit','编辑月度汇率',NULL,'PUT',912,'ACTIVE',1,0), + (5107213,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:status','变更月度汇率状态',NULL,'PATCH',913,'ACTIVE',1,0), + (5107214,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:export','导出月度汇率',NULL,'GET',914,'ACTIVE',1,0), + (5107220,0,'PLATFORM','MENU',0,'platform:overseas-master-data:vehicle:list','车辆主数据','/overseas-master-data/vehicles','GET',920,'ACTIVE',1,0), + (5107221,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:create','创建车辆主数据',NULL,'POST',921,'ACTIVE',1,0), + (5107222,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:edit','编辑车辆主数据',NULL,'PUT',922,'ACTIVE',1,0), + (5107223,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:status','变更车辆状态',NULL,'PATCH',923,'ACTIVE',1,0), + (5107224,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:export','导出车辆主数据',NULL,'GET',924,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), + http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0; + +INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM baiyue_iam.iam_role role +JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.id BETWEEN 5107000 AND 5107299 +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + + CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql new file mode 100644 index 000000000..975fabc23 --- /dev/null +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -0,0 +1,254 @@ +-- 海外区域固定 SQL 入口。只由人工审核执行;服务部署默认不自动执行 SQL。 +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_event_outbox ( + id BIGINT NOT NULL COMMENT '事件主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', event_version INT NOT NULL COMMENT '事件版本', + event_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', payload_json LONGTEXT NOT NULL COMMENT '事件载荷JSON', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', published_at DATETIME(3) NULL COMMENT '发布时间', + locked_at DATETIME(3) NULL COMMENT '投递租约获取时间', locked_by VARCHAR(128) NULL COMMENT '投递租约持有者', + last_error VARCHAR(1000) NULL COMMENT '最后一次发布错误', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_event_outbox_key (event_key), + KEY idx_ll_event_outbox_publish (status,next_retry_at,created_at) +) ENGINE=InnoDB COMMENT='尾程跨区业务事件发件箱'; + +CREATE TABLE IF NOT EXISTS ll_overseas_quote ( + id BIGINT NOT NULL COMMENT '报价ID', name VARCHAR(128) NOT NULL COMMENT '报价名称', + warehouse_id BIGINT NOT NULL COMMENT '国内平台海外合作仓ID', warehouse_code VARCHAR(64) NOT NULL COMMENT '合作仓编码快照', + warehouse_name VARCHAR(128) NOT NULL COMMENT '合作仓名称快照', country_code VARCHAR(2) NOT NULL COMMENT '国家二字码快照', + provider_name VARCHAR(128) NOT NULL COMMENT '报价方', currency CHAR(3) NOT NULL COMMENT '币种', + effective_start DATE NOT NULL COMMENT '生效开始日期', effective_end DATE NOT NULL COMMENT '生效结束日期', + version_code VARCHAR(64) NOT NULL COMMENT '业务版本号', status VARCHAR(16) NOT NULL DEFAULT 'INACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_warehouse_provider_version (warehouse_id,provider_name,version_code), + KEY idx_ll_quote_page (status,updated_at,id), KEY idx_ll_quote_effective (warehouse_id,provider_name,currency,effective_start,effective_end) +) ENGINE=InnoDB COMMENT='海外仓报价主表'; + +CREATE TABLE IF NOT EXISTS ll_overseas_quote_fee ( + id BIGINT NOT NULL COMMENT '费用明细ID', quote_id BIGINT NOT NULL COMMENT '报价ID', sequence_no INT NOT NULL COMMENT '显示顺序', + item VARCHAR(128) NOT NULL COMMENT '费用项目', billing_unit VARCHAR(64) NOT NULL COMMENT '计费单位', + unit_price DECIMAL(18,4) NOT NULL COMMENT '单价', minimum_charge DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低收费', + remark VARCHAR(500) NULL COMMENT '备注', PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_fee_sequence (quote_id,sequence_no), + KEY idx_ll_quote_fee_quote (quote_id) +) ENGINE=InnoDB COMMENT='海外仓报价仓库费用'; + +CREATE TABLE IF NOT EXISTS ll_overseas_quote_transit_price ( + id BIGINT NOT NULL COMMENT '中转价格ID', quote_id BIGINT NOT NULL COMMENT '报价ID', sequence_no INT NOT NULL COMMENT '显示顺序', + fba_warehouse_code VARCHAR(64) NOT NULL COMMENT 'FBA仓编码', delivery_method VARCHAR(64) NOT NULL COMMENT '报价运输方式', + estimated_miles DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '预估英里数', price DECIMAL(18,4) NOT NULL COMMENT '报价金额', + minimum_charge DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低收费', remark VARCHAR(500) NULL COMMENT '备注', + PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_transit_sequence (quote_id,sequence_no), + KEY idx_ll_quote_transit_quote (quote_id) +) ENGINE=InnoDB COMMENT='海外仓报价中转价格明细'; + +CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate ( + id BIGINT NOT NULL COMMENT '月度汇率ID', rate_month CHAR(7) NOT NULL COMMENT '汇率月份,格式yyyy-MM', + base_currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '基准币种', quote_currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '报价币种', + usd_to_cny_average DECIMAL(18,8) NOT NULL COMMENT '美元兑人民币月平均汇率', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_exchange_rate_month (rate_month), KEY idx_ll_exchange_rate_page (status,rate_month) +) ENGINE=InnoDB COMMENT='美元兑人民币月度平均汇率'; + +CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate_history ( + id BIGINT NOT NULL COMMENT '汇率变更历史ID', exchange_rate_id BIGINT NOT NULL COMMENT '月度汇率ID', + before_rate DECIMAL(18,8) NULL COMMENT '变更前汇率', after_rate DECIMAL(18,8) NOT NULL COMMENT '变更后汇率', + before_status VARCHAR(16) NULL COMMENT '变更前状态', after_status VARCHAR(16) NOT NULL COMMENT '变更后状态', + operator_id VARCHAR(128) NOT NULL COMMENT '操作人', remark VARCHAR(1000) NULL COMMENT '变更说明', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', PRIMARY KEY (id), KEY idx_ll_exchange_rate_history (exchange_rate_id,created_at) +) ENGINE=InnoDB COMMENT='月度汇率变更历史'; + +CREATE TABLE IF NOT EXISTS ll_vehicle ( + id BIGINT NOT NULL COMMENT '车辆档案ID', plate_number VARCHAR(64) NOT NULL COMMENT '车牌号', + model VARCHAR(128) NOT NULL COMMENT '车型', fuel_consumption_per100_km DECIMAL(10,2) NOT NULL COMMENT '百公里油耗,单位升', + mileage_km BIGINT NOT NULL COMMENT '当前公里码,单位公里', driver_or_dispatch_company VARCHAR(128) NULL COMMENT '司机姓名或外派单位', + phone VARCHAR(64) NULL COMMENT '司机或外派单位联系电话', region VARCHAR(16) NOT NULL COMMENT '区域:DOMESTIC/OVERSEAS', + ownership VARCHAR(16) NOT NULL COMMENT '车辆归属:OWNED/DISPATCHED', + status VARCHAR(32) NOT NULL COMMENT '状态:IN_SERVICE/DISPATCHED/MAINTENANCE/INACTIVE/RETURNED', + remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_vehicle_plate (plate_number), KEY idx_ll_vehicle_page (ownership,region,status,updated_at,id) +) ENGINE=InnoDB COMMENT='平台车辆主数据'; + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + customer_name VARCHAR(255) NULL COMMENT '客户名称快照', order_no VARCHAR(64) NOT NULL COMMENT '订单号', + purchase_category VARCHAR(128) NOT NULL COMMENT '采购品类', product_name VARCHAR(255) NOT NULL COMMENT '产品名称', + product_specification VARCHAR(500) NULL COMMENT '产品规格', product_url VARCHAR(2000) NULL COMMENT '产品链接', + purchase_quantity BIGINT NOT NULL COMMENT '采购数量', budget_unit_price DECIMAL(18,2) NULL COMMENT '预算单价', + currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', receiving_address VARCHAR(1000) NULL COMMENT '收货地址', + contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', + purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', + inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '采购单据累计入库数量', domestic_carrier VARCHAR(128) NULL COMMENT '境内承运商', + domestic_tracking_no VARCHAR(128) NULL COMMENT '境内物流单号', overseas_carrier VARCHAR(128) NULL COMMENT '海外承运商', + overseas_tracking_no VARCHAR(128) NULL COMMENT '海外物流单号', estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', + review_remark VARCHAR(1000) NULL COMMENT '审核备注', reviewed_at DATETIME(3) NULL COMMENT '审核时间', + version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_overseas_procurement_order_no(customer_id,order_no), + UNIQUE KEY uk_ll_overseas_procurement_order_key(customer_id,idempotency_key), + KEY idx_ll_overseas_procurement_list(customer_id,status,created_at), + KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at) +) ENGINE=InnoDB COMMENT='海外采购订单'; + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE', from_status VARCHAR(32) NULL COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + KEY idx_ll_overseas_procurement_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='海外采购操作流水'; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_no VARCHAR(64) NOT NULL COMMENT '订单号', + transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运地址', + destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的地址', + container_type VARCHAR(32) NULL COMMENT '历史兼容设备类型,零担托盘业务不采集', pallet_count INT NOT NULL DEFAULT 1 COMMENT '客户申报托盘数', + package_count INT NULL COMMENT '可选箱数,零或空值均有效', received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数', + allocated_pallet_count INT NOT NULL DEFAULT 0 COMMENT '已分配到合车行程的托盘数', shipped_pallet_count INT NOT NULL DEFAULT 0 COMMENT '已出库托盘数', + total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', + budget_amount DECIMAL(18,2) NULL COMMENT '预算金额', currency VARCHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', + contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_info VARCHAR(128) NOT NULL COMMENT '联系人联系方式', + requested_date DATE NOT NULL COMMENT '要求服务日期', status VARCHAR(32) NOT NULL COMMENT '美国卡车订单状态', remark VARCHAR(1000) NULL COMMENT '备注', + receipt_idempotency_key VARCHAR(255) NULL COMMENT '实物收货幂等键', receipt_request_hash CHAR(64) NULL COMMENT '实物收货请求哈希', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', + version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_us_truck_order_no(customer_id,order_no), UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key), + UNIQUE KEY uk_ll_us_truck_receipt_key(customer_id,receipt_idempotency_key), KEY idx_ll_us_truck_order_list(customer_id,status,created_at), + KEY idx_ll_us_truck_order_date(customer_id,requested_date,created_at) +) ENGINE=InnoDB COMMENT='美国卡车运输订单'; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet ( + id BIGINT NOT NULL COMMENT '托盘记录ID', order_id BIGINT NOT NULL COMMENT '美国卡车订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + pallet_no VARCHAR(128) NOT NULL COMMENT '业务托盘号', sequence_no INT NOT NULL COMMENT '订单内托盘序号', + source VARCHAR(32) NOT NULL COMMENT '托盘来源:订单计划或PDA超收', status VARCHAR(32) NOT NULL COMMENT '托盘状态:待收、已收或超收待审核', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘资源ID', package_count INT NULL COMMENT '托盘箱数', received_at DATETIME(3) NULL COMMENT '实收时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_us_truck_order_pallet_no(customer_id,order_id,pallet_no), UNIQUE KEY uk_ll_us_truck_order_pallet_seq(customer_id,order_id,sequence_no), + KEY idx_ll_us_truck_order_pallet_list(customer_id,order_id,status) +) ENGINE=InnoDB COMMENT='美国卡车订单托盘实收记录'; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + KEY idx_ll_us_truck_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='美国卡车订单状态历史'; + +CREATE TABLE IF NOT EXISTS ll_local_looseboard_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', + transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载', pallet_required VARCHAR(8) NOT NULL COMMENT '是否需要打盘:YES或NO', + transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址', + destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的详细地址、码头或仓号', + container_type VARCHAR(32) NULL COMMENT '柜型', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数', + total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', + budget_amount DECIMAL(18,2) NULL COMMENT '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种', + contact_name VARCHAR(128) NULL COMMENT '联系人姓名', contact_info VARCHAR(128) NULL COMMENT '联系人联系方式', + requested_date DATE NULL COMMENT '要求服务日期', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态', + remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_local_looseboard_no(customer_id,order_no), UNIQUE KEY uk_ll_local_looseboard_key(customer_id,idempotency_key), + KEY idx_ll_local_looseboard_list(customer_id,status,created_at), KEY idx_ll_local_looseboard_date(customer_id,requested_date,created_at) +) ENGINE=InnoDB COMMENT='本土散板订单'; + +CREATE TABLE IF NOT EXISTS ll_local_looseboard_history ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、UPDATE或STATUS', from_status VARCHAR(32) NULL COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + KEY idx_ll_local_looseboard_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='本土散板操作流水'; + +-- 入仓库存按 SKU 冻结维度;旧环境只补结构,不由部署自动执行。 +CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton ( + id BIGINT NOT NULL COMMENT '箱唛作业主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_id BIGINT NULL COMMENT '海外首扫确权后的尾程订单ID', source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入库明细ID', + sku_code VARCHAR(128) NULL COMMENT 'SKU编码快照', carton_mark VARCHAR(128) NOT NULL COMMENT '国内冻结箱唛', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID', + line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID', + warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID', + status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark), + KEY idx_ll_inbound_carton_plan(plan_id,status), KEY idx_ll_inbound_carton_pallet(plan_pallet_id,status), KEY idx_ll_inbound_carton_order(order_id,status) +) ENGINE=InnoDB COMMENT='柜次冻结箱唛及海外入库箱级作业账'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), + 'ALTER TABLE ll_order MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID''', + 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); +PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_file_ids'), + 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表''', + 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表'' AFTER completed_at')); +PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_uploaded_at'), + 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间''', + 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间'' AFTER unload_photo_file_ids')); +PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='estimated_empty_return_at'), + 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)''', + 'ALTER TABLE ll_inbound_pda_task ADD COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)'' AFTER unload_photo_uploaded_at')); +PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND index_name='uk_ll_inbound_plan_container'), + 'ALTER TABLE ll_inbound_plan DROP INDEX uk_ll_inbound_plan_container', 'SELECT 1')); +PREPARE phase4_inbound_stmt FROM @ddl; EXECUTE phase4_inbound_stmt; DEALLOCATE PREPARE phase4_inbound_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND index_name='idx_ll_inbound_plan_container'), + 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD KEY idx_ll_inbound_plan_container (customer_id,container_no)')); +PREPARE phase4_inbound_stmt FROM @ddl; EXECUTE phase4_inbound_stmt; DEALLOCATE PREPARE phase4_inbound_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='sku_code'), + 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码;无SKU使用UNSPECIFIED'' AFTER inbound_code')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='product_name_zh'), + 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照'' AFTER sku_code')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='product_name_en'), + 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照'' AFTER product_name_zh')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'), + 'ALTER TABLE ll_inventory_balance DROP INDEX uk_ll_inventory_dimension', 'SELECT 1')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'), + 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code,sku_code)')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='idx_ll_inventory_sku'), + 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD KEY idx_ll_inventory_sku (customer_id,sku_code,updated_at)')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='sku_code'), + 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码快照'' AFTER inbound_code')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='product_name_zh'), + 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照'' AFTER sku_code')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='product_name_en'), + 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照'' AFTER product_name_zh')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation'), + 'ALTER TABLE ll_inbound_stock_receipt DROP INDEX uk_ll_stock_receipt_task_allocation', 'SELECT 1')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation_sku'), + 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (customer_id,task_id,allocation_id,sku_code)')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; + +SELECT 'PASS' AS migration_status, '海外仓报价、月度汇率、车辆主数据、海外业务与SKU库存结构已就绪' AS message; diff --git a/docs/头程项目目录结构与服务说明.md b/docs/头程项目目录结构与服务说明.md index f1a996425..9c15e66f1 100644 --- a/docs/头程项目目录结构与服务说明.md +++ b/docs/头程项目目录结构与服务说明.md @@ -86,6 +86,7 @@ baiyue-firstleg/ | 文件夹 | 注册服务名 | 端口 | 数据库 | 当前职责与实现情况 | | --- | --- | ---: | --- | --- | | `gateway` | `gateway` | 9000 | 无 | 统一路由、CORS、JWT/Redis 会话校验、商户端与平台端访问域隔离、Swagger 聚合 | +| `gateway-overseas` | `gateway-overseas` | 9300 | 无 | 海外区域 API 入口、海外 Audience/JWKS 校验及海外服务路由 | | `auth` | `auth-service` | 9100 | `baiyue_auth` | 登录、刷新 Token、退出、商户注册、账号密码和账号内部管理 | | `iam` | `iam-service` | 9201 | `baiyue_iam` | 租户、企业资料、企业审核、成员、角色、权限、按钮权限、个人资料和授权快照 | | `firstleg` | `firstleg-service` | 9202 | `baiyue_firstleg` | 履约任务与分配;二期入库单、测量审核、FBA 计费、费用发布和完成入库编排;不是订单主数据服务 | @@ -94,10 +95,12 @@ baiyue-firstleg/ | `file` | `file-service` | 9206 | `baiyue_file` | 企业资质文件、公共异步导出下载、入库测量图片及重量 OCR 调用协作 | | `print` | `print-service` | 9207 | 无 | 无状态 PDF 渲染服务;调用方传入完整标签数据,服务在内存中生成一次性多页 PDF | | `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 | -| `event-bridge-service` | `event-bridge-service` | 9300 | `baiyue_event_bridge` | 国内外事件接收、签名/内部令牌保护、幂等 Inbox 和接收结果 | +| `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存与海外本土业务;不含海外出库和派送 | +| `warehouse-overseas-service` | `warehouse-overseas-service` | 9305 | 海外仓资源库 | 海外仓库、库位、托盘和资源锁 | +| `event-bridge-service` | `event-bridge-service` | 9312 | `baiyue_event_bridge` | 按 CN/OVERSEAS 双实例部署的跨区事件 Inbox、重试和目标服务投递 | | `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;平台端可按权限查询已脱敏日志,业务审计发送默认开启 | -MySQL、Redis、Nacos 是外部基础设施,不属于上述 11 个业务服务,也不应打包进业务镜像。 +MySQL、Redis、Nacos 是外部基础设施,不属于上述业务服务,也不应打包进业务镜像。 ## 5. 各服务文件夹说明 diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java index e26f92210..575bd2867 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java @@ -36,7 +36,7 @@ public class EventBridgeController { @Value("${baiyue.region:OVERSEAS}") private String region; - @Operation(summary = "幂等接收事件", description = "当前仅完成数据库接收骨架,不代表生产跨区传输和 mTLS 已开通") + @Operation(summary = "幂等接收事件", description = "按当前部署区域校验目标区域、事件信封与 payloadHash,幂等持久化后进入租约保护的重试投递队列") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "事件已接收或判定为重复"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "内部服务令牌无效"), diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java index 146bb4a5c..25b7fc7c3 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java @@ -14,6 +14,7 @@ import org.springframework.transaction.annotation.Transactional; import jakarta.annotation.Resource; import java.time.LocalDateTime; +import java.util.Objects; /** * 入站事件校验与幂等落库服务。 @@ -32,9 +33,8 @@ public class EventInboxService { if (!envelope.targetRegion().name().equalsIgnoreCase(localRegion)) { throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致"); } - boolean currentHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash()); - boolean legacyHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalizeLegacy(envelope.payload()), envelope.payloadHash()); - if (!currentHash && !legacyHash) { + if (!EventPayloadHash.matches( + EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { throw new IllegalArgumentException("payloadHash 与规范化事件负载不一致"); } BridgeInbox existing = inboxStore.findByEventId(envelope.eventId()); @@ -55,12 +55,30 @@ public class EventInboxService { } private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) { - if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { - throw new IllegalArgumentException("相同 eventId 对应的 payloadHash 不一致,拒绝覆盖既有事件"); + if (!sameEnvelope(existing, envelope)) { + throw new IllegalArgumentException("相同 eventId 对应的事件信封或 payloadHash 不一致,拒绝覆盖既有事件"); } return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt()); } + private boolean sameEnvelope(BridgeInbox existing, EventEnvelope envelope) { + return existing.getPayloadHash() != null + && existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash()) + && Objects.equals(existing.getEventType(), envelope.eventType()) + && Objects.equals(existing.getEventVersion(), envelope.eventVersion()) + && Objects.equals(existing.getSourceRegion(), envelope.sourceRegion().name()) + && Objects.equals(existing.getTargetRegion(), envelope.targetRegion().name()) + && Objects.equals(existing.getCustomerId(), envelope.tenantId()) + && Objects.equals(existing.getAggregateType(), envelope.aggregateType()) + && Objects.equals(existing.getAggregateId(), envelope.aggregateId()) + && Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion()) + && Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion()) + && Objects.equals(existing.getOccurredAt(), envelope.occurredAt()) + && Objects.equals(existing.getTraceId(), envelope.traceId()) + && Objects.equals(existing.getCorrelationId(), envelope.correlationId()) + && Objects.equals(existing.getCausationId(), envelope.causationId()); + } + private BridgeInbox toInbox(EventEnvelope envelope) { BridgeInbox inbox = new BridgeInbox(); inbox.setEventId(envelope.eventId()); diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml index 53a1bdc39..5d83c9689 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -19,7 +19,7 @@ spring: password: ${EVENT_BRIDGE_DB_PASSWORD:123456} cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} username: ${NACOS_USERNAME:nacos} password: ${NACOS_PASSWORD:nacos} discovery: @@ -31,7 +31,7 @@ spring: baiyue: region: ${REGION_CODE:OVERSEAS} event-dispatcher: - consumer-url: ${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events} + consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java index 1d5d45152..9ae12a6dc 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java @@ -36,7 +36,7 @@ class EventInboxServiceTest { } @Test - void 数字规范化上线前的历史摘要仍可接收() { + void 拒绝旧数字规范化摘要() { EventEnvelope source = envelope("sha256:placeholder"); source.payload().withObject("/snapshot").put("weight", 30.0).put("volume", 0.3150); String legacyHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(source.payload())); @@ -45,13 +45,15 @@ class EventInboxServiceTest { source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), source.traceId(), source.correlationId(), source.causationId(), source.payload(), legacyHash); - assertThat(service.receive(envelope).result()).isEqualTo("已接收"); + assertThatThrownBy(() -> service.receive(envelope)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("payloadHash"); } @Test void 相同事件和摘要重复接收不再次写入() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - BridgeInbox existing = existing(envelope.payloadHash()); + BridgeInbox existing = existing(envelope, envelope.payloadHash()); when(store.findByEventId(envelope.eventId())).thenReturn(existing); assertThat(service.receive(envelope).result()).isEqualTo("重复"); @@ -60,17 +62,31 @@ class EventInboxServiceTest { @Test void 相同事件但摘要不同时拒绝覆盖() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - when(store.findByEventId(envelope.eventId())).thenReturn(existing("sha256:" + "0".repeat(64))); + when(store.findByEventId(envelope.eventId())).thenReturn(existing(envelope, "sha256:" + "0".repeat(64))); assertThatThrownBy(() -> service.receive(envelope)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("拒绝覆盖"); } + @Test + void 相同事件和摘要但聚合标识不同时拒绝覆盖() { + EventEnvelope envelope = withHash(envelope("sha256:placeholder")); + EventEnvelope conflicting = new EventEnvelope(envelope.eventId(), envelope.eventType(), envelope.eventVersion(), + envelope.occurredAt(), envelope.sourceRegion(), envelope.targetRegion(), envelope.tenantId(), + envelope.aggregateType(), "DIFFERENT", envelope.aggregateVersion(), envelope.handoverVersion(), + envelope.traceId(), envelope.correlationId(), envelope.causationId(), envelope.payload(), envelope.payloadHash()); + when(store.findByEventId(conflicting.eventId())).thenReturn(existing(envelope, envelope.payloadHash())); + + assertThatThrownBy(() -> service.receive(conflicting)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("事件信封"); + } + @Test void 并发插入冲突后按既有记录判定为重复() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope.payloadHash())); + when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope, envelope.payloadHash())); doThrow(new DuplicateKeyException("并发重复")).when(store).insert(any()); assertThat(service.receive(envelope).result()).isEqualTo("重复"); @@ -109,9 +125,22 @@ class EventInboxServiceTest { source.correlationId(), source.causationId(), source.payload(), hash); } - private BridgeInbox existing(String hash) { + private BridgeInbox existing(EventEnvelope envelope, String hash) { BridgeInbox inbox = new BridgeInbox(); - inbox.setEventId("550e8400-e29b-41d4-a716-446655440000"); + inbox.setEventId(envelope.eventId()); + inbox.setEventType(envelope.eventType()); + inbox.setEventVersion(envelope.eventVersion()); + inbox.setSourceRegion(envelope.sourceRegion().name()); + inbox.setTargetRegion(envelope.targetRegion().name()); + inbox.setCustomerId(envelope.tenantId()); + inbox.setAggregateType(envelope.aggregateType()); + inbox.setAggregateId(envelope.aggregateId()); + inbox.setAggregateVersion(envelope.aggregateVersion()); + inbox.setHandoverVersion(envelope.handoverVersion()); + inbox.setOccurredAt(envelope.occurredAt()); + inbox.setTraceId(envelope.traceId()); + inbox.setCorrelationId(envelope.correlationId()); + inbox.setCausationId(envelope.causationId()); inbox.setPayloadHash(hash); inbox.setReceivedAt(LocalDateTime.of(2026, 7, 29, 18, 0)); return inbox; diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 6a6e0ec51..1a711b5d1 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -28,8 +28,9 @@ | 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`loading/` | | PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` | | 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`client/EventBridgeClient.java`;事件模型见 `../common-event-contract/AGENTS.md` | +| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | -| 四期国内出仓后船节点定时查询 | 现有 `portschedule/controller/PlatformCarrierScheduleQueryController.java`、`service/CarrierScheduleQueryService.java` 及查询任务、尝试、历史整体归四期;新增定时编排、节点事实或到港衔接继续归该子域,外部船司/轨迹调用经 `integration-service/`;范围见 `../../docs/四期/README.md` | +| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/标准节点事实/历史 `CarrierScheduleQueryService.java`,内部停止入口 `controller/InternalCarrierScheduleQueryController.java`,平台只读入口 `PlatformCarrierScheduleQueryController.java`;外部船司/轨迹调用经 `integration-service/`;范围见 `../../docs/四期/README.md` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 拖车与出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java` | | 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` | @@ -49,7 +50,8 @@ - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 - 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 -- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次,保存来源、节点观测时间、最近成功结果、失败尝试及完整历史,并以幂等方式把确认到港/可提柜结果衔接给海外区域。现有 `CarrierScheduleQuery` 及“每 3 天一次、窗口最多 3 次”约束属于四期;外部失败不得推进业务节点,不得在三期另建查询任务。 +- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;标准节点固定为 `DEPARTED_WAREHOUSE → AT_ORIGIN_PORT → DEPARTED_ORIGIN_PORT → IN_TRANSIT → ARRIVED → PICKUP_READY`,保存来源、节点观测时间、最近成功结果、失败尝试及完整历史。现有 `CarrierScheduleQuery` 及“正常每 3 天一次、窗口最多 3 次、失败指数退避”约束属于四期;租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。 +- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。 - Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 ## 三期装柜与 PDA 资源结算 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java new file mode 100644 index 000000000..89606b826 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java @@ -0,0 +1,28 @@ +package com.baiyue.firstleg.client; + +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; +import java.util.List; + +@FeignClient(name = "integration-service", contextId = "carrierScheduleIntegrationClient", + url = "${baiyue.integration-service.url:}") +public interface CarrierScheduleIntegrationClient { + @PostMapping("/internal/integrations/carrier-schedules/query") + Envelope query(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody QueryRequest request); + + record Envelope(String code, String message, T data) { + public boolean successful() { return "200".equals(code); } + } + record QueryRequest(String carrierCode, String trackingType, String trackingNo) { } + record QueryResult(String providerCode, String outcome, List observations, + String errorCode, String errorMessage, LocalDateTime providerObservedAt) { } + record Observation(String providerEventId, String providerNodeCode, String standardNode, + LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt, + String locationCode, String locationName, String sourceReference, + String rawPayloadHash) { } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java index 8abce64c9..07375297d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java @@ -14,6 +14,14 @@ public final class ContainerOverviewDtos { List timeline) {} public record ContainerBookingEntityView(Long id, String bookingNo, String cabinetNo, String containerNo, String status, Long warehouseId, String warehouseName, String voyageNo, String vesselName, + String billOfLadingNo, Long shippingCompanyId, String shippingCompanyName, + String departurePortCode, String departurePortName, String destinationPortCode, + String destinationPortName, LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt, + LocalDateTime estimatedPortArrivalAt, LocalDateTime actualPortArrivalAt, + LocalDateTime estimatedWarehouseArrivalAt, LocalDateTime actualWarehouseArrivalAt, + LocalDateTime unloadingCompletedAt, LocalDateTime unloadingPhotoUploadedAt, + LocalDateTime estimatedEmptyReportedAt, LocalDateTime actualEmptyReportedAt, + Long emptyReportSourceVersion, Integer version) {} public record PdaSummary(Long taskId, Long loadingPlanId, Long containerBookingId, String taskStatus, String workflowStatus, Integer taskVersion, Integer itemCount, Integer completedItemCount, Integer unresolvedItemCount, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index da7d5450e..50ec10a34 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -116,7 +116,7 @@ public interface ContainerBookingMapper extends BaseMapper contributors; + private final CarrierScheduleQueryService carrierSchedules; + @Autowired public OutboundDispatchService(OutboundDispatchRepository repository, - List contributors) { + List contributors, + CarrierScheduleQueryService carrierSchedules) { this.repository = repository; + this.carrierSchedules = carrierSchedules; Map indexed = new HashMap<>(); for (OutboundDispatchGateContributor contributor : contributors) { if (indexed.put(contributor.code(), contributor) != null) { @@ -39,6 +45,11 @@ public class OutboundDispatchService { this.contributors = Map.copyOf(indexed); } + public OutboundDispatchService(OutboundDispatchRepository repository, + List contributors) { + this(repository, contributors, null); + } + public List list(Query query) { requireSelector(query); return repository.bindings(query).stream().map(this::aggregate).toList(); @@ -73,6 +84,10 @@ public class OutboundDispatchService { } history(state, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(), OutboundDispatchStatus.WAITING_FOR_LOADING.name(), request.remark(), operatorId, operatorName); + if (carrierSchedules != null) { + carrierSchedules.ensureFromOutboundConfirm(binding.tenantId(), before.oceanBookingId(), + binding.containerBookingId(), operatorId, operatorName); + } if (repository.completeOperation(state.getId(), operatorId, "CONFIRM_DISPATCH", key) != 1) throw concurrent(); return detail(query); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalCarrierScheduleQueryController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalCarrierScheduleQueryController.java new file mode 100644 index 000000000..3187f3912 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalCarrierScheduleQueryController.java @@ -0,0 +1,48 @@ +package com.baiyue.firstleg.portschedule.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.portschedule.dto.CarrierScheduleQueryDtos.Detail; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.PathVariable; +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/carrier-schedule-queries") +public class InternalCarrierScheduleQueryController { + private final CarrierScheduleQueryService service; + @Value("${baiyue.security.internal-service-token}") private String expectedToken; + + public InternalCarrierScheduleQueryController(CarrierScheduleQueryService service) { this.service = service; } + + @PostMapping("/{queryId}/stop") + public ApiResponse stop(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable @Positive long queryId, + @Valid @RequestBody StopRequest request) { + authenticate(token); + return ApiResponse.success(service.stop(queryId, request.reason())); + } + + private void authenticate(String token) { + if (token == null || expectedToken == null || expectedToken.isBlank() + || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), + expectedToken.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效"); + } + } + + public record StopRequest(@NotBlank @Size(max = 500) String reason) { } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java index a98ec7d28..90a5ce73a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java @@ -4,14 +4,13 @@ import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.AttemptRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView; -import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History; import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; import com.baiyue.firstleg.service.FirstlegScopeGuard; import io.swagger.v3.oas.annotations.Operation; @@ -66,7 +65,7 @@ public class PlatformCarrierScheduleQueryController { @GetMapping("/{queryId}/history") @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')") - public ApiResponse> history( + public ApiResponse> history( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long queryId) { FirstlegScopeGuard.platform(user, service.customerId(queryId)); return ApiResponse.success(service.history(queryId)); @@ -83,18 +82,6 @@ public class PlatformCarrierScheduleQueryController { return ApiResponse.success(service.create(user.userId(), user.username(), key, request)); } - @PostMapping("/{queryId}/attempts") - @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:attempt')") - @BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_ATTEMPT", resourceType = "CARRIER_SCHEDULE_QUERY", - resourceId = "#queryId", description = "执行船司官网查询策略") - public ApiResponse attempt(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long queryId, - @RequestHeader("Idempotency-Key") String key, - @Valid @RequestBody AttemptRequest request) { - FirstlegScopeGuard.platform(user, service.customerId(queryId)); - return ApiResponse.success(service.attempt(queryId, user.userId(), user.username(), key, request)); - } - @PostMapping("/{queryId}/manual-results") @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')") @BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_RESULT", resourceType = "CARRIER_SCHEDULE_QUERY", diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleNode.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleNode.java new file mode 100644 index 000000000..724fc5524 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleNode.java @@ -0,0 +1,29 @@ +package com.baiyue.firstleg.portschedule.domain; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; + +import java.util.Locale; + +/** Provider-independent maritime milestones in monotonic business order. */ +public enum CarrierScheduleNode { + DEPARTED_WAREHOUSE(10), + AT_ORIGIN_PORT(20), + DEPARTED_ORIGIN_PORT(30), + IN_TRANSIT(40), + ARRIVED(50), + PICKUP_READY(60); + + private final int sequence; + + CarrierScheduleNode(int sequence) { this.sequence = sequence; } + + public int sequence() { return sequence; } + + public static CarrierScheduleNode require(String value) { + try { return valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); } + catch (IllegalArgumentException exception) { + throw new BizException(ResultCode.BAD_REQUEST, "不支持的船节点:" + value); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java index 07a06443d..b0fe8b792 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java @@ -1,8 +1,5 @@ package com.baiyue.firstleg.portschedule.dto; -import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity; -import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; -import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; @@ -33,20 +30,44 @@ public final class CarrierScheduleQueryDtos { @NotBlank @Size(max = 128) String trackingNo, @Size(max = 1000) String remark) {} - public record AttemptRequest(@NotNull @Min(0) Integer expectedVersion, - @Size(max = 1000) String reason) {} - public record ManualResultRequest(@NotNull @Min(0) Integer expectedVersion, @NotNull @Positive Long portScheduleId, @NotBlank @Size(max = 1000) String reason) {} + public record ProviderObservationRequest(@NotBlank @Size(max = 32) String providerCode, + @NotBlank @Size(max = 128) String providerEventId, + @NotBlank @Size(max = 64) String providerNodeCode, + @NotBlank @Size(max = 32) String standardNode, + @NotNull LocalDateTime observedAt, + LocalDateTime estimatedAt, + LocalDateTime actualAt, + @Size(max = 32) String locationCode, + @Size(max = 128) String locationName, + @Size(max = 500) String sourceReference, + @NotBlank @Size(min = 64, max = 64) String rawPayloadHash) {} + public record CarrierDescriptor(String carrierCode, String carrierName, String officialUrl, String accessMode, boolean automated, boolean coverageGap, String coverageGapCode, String manualReason) {} - public record QueryView(CarrierScheduleQueryEntity query, boolean expired, - int maxAttempts, LocalDateTime nextAllowedAt) {} + public record Task(Long id, String queryNo, Long customerId, Long oceanBookingId, + Long containerBookingId, String carrierCode, String trackingType, String trackingNo, + String officialUrl, String accessMode, String status, String triggerSource, + String latestStandardNode, LocalDateTime latestNodeObservedAt, + LocalDateTime lastSuccessAt, LocalDateTime sourceUpdatedAt, LocalDateTime expiresAt, + Long resultPortScheduleId, String manualReason, String lastErrorCode, + String lastErrorMessage, LocalDateTime stoppedAt, String stopReason, Integer version) {} - public record Detail(QueryView query, List attempts, - List histories) {} + public record QueryView(Task query, boolean expired) {} + + public record NodeFact(Long id, String providerCode, String providerNodeCode, String standardNode, + int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt, + LocalDateTime actualAt, String locationCode, String locationName, + String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {} + + public record History(Long id, String action, Integer fromVersion, Integer toVersion, String reason, + Long operatorId, String operatorName, LocalDateTime createdAt) {} + + /** Frontend detail intentionally excludes scheduler leases and raw query attempts. */ + public record Detail(QueryView query, List nodes, List histories) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java new file mode 100644 index 000000000..3015f55c3 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java @@ -0,0 +1,32 @@ +package com.baiyue.firstleg.portschedule.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("fl_carrier_schedule_node_fact") +public class CarrierScheduleNodeFactEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private Long queryId; + private String eventKey; + private String providerCode; + private String providerNodeCode; + private String standardNode; + private Integer nodeSequence; + private LocalDateTime observedAt; + private LocalDateTime estimatedAt; + private LocalDateTime actualAt; + private String locationCode; + private String locationName; + private String sourceReference; + private String rawPayloadHash; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryEntity.java index fd460a582..748aff208 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryEntity.java @@ -26,6 +26,14 @@ public class CarrierScheduleQueryEntity { private String officialUrl; private String accessMode; private String status; + private String triggerSource; + private String latestStandardNode; + private LocalDateTime latestNodeObservedAt; + private LocalDateTime stoppedAt; + private String stopReason; + private String leaseOwner; + private LocalDateTime leaseUntil; + private Integer consecutiveFailures; private LocalDateTime windowStartedAt; private Integer attemptCount; private LocalDateTime nextAllowedAt; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java new file mode 100644 index 000000000..a5aac0ec7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java @@ -0,0 +1,23 @@ +package com.baiyue.firstleg.portschedule.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface CarrierScheduleNodeFactMapper extends BaseMapper { + @Insert(""" + INSERT INTO fl_carrier_schedule_node_fact + (id,query_id,event_key,provider_code,provider_node_code,standard_node,node_sequence,observed_at, + estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at) + VALUES + (#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerNodeCode}, + #{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt}, + #{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash}, + CURRENT_TIMESTAMP(3)) + ON DUPLICATE KEY UPDATE id=id + """) + int insertIgnore(@Param("value") CarrierScheduleNodeFactEntity value); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java index cc973ee0b..69b0d47f2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java @@ -9,6 +9,9 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; +import java.time.LocalDateTime; +import java.util.List; + @Mapper public interface CarrierScheduleQueryMapper extends BaseMapper { @Select(""" @@ -33,9 +36,42 @@ public interface CarrierScheduleQueryMapper extends BaseMapper selectDue(@Param("now") LocalDateTime now, @Param("limit") int limit); + + @Update(""" + UPDATE fl_carrier_schedule_query SET lease_owner=#{owner},lease_until=#{leaseUntil}, + updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{id} AND status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY') + AND next_allowed_at IS NOT NULL AND next_allowed_at<=#{now} + AND (lease_until IS NULL OR lease_until<#{now}) + """) + int claimLease(@Param("id") long id, @Param("owner") String owner, + @Param("now") LocalDateTime now, @Param("leaseUntil") LocalDateTime leaseUntil); + @Update(""" UPDATE fl_carrier_schedule_query SET official_url=#{value.officialUrl},access_mode=#{value.accessMode},status=#{value.status}, + trigger_source=#{value.triggerSource},latest_standard_node=#{value.latestStandardNode}, + latest_node_observed_at=#{value.latestNodeObservedAt},stopped_at=#{value.stoppedAt}, + stop_reason=#{value.stopReason},lease_owner=#{value.leaseOwner},lease_until=#{value.leaseUntil}, + consecutive_failures=#{value.consecutiveFailures}, window_started_at=#{value.windowStartedAt},attempt_count=#{value.attemptCount}, next_allowed_at=#{value.nextAllowedAt},last_attempt_at=#{value.lastAttemptAt}, last_success_at=#{value.lastSuccessAt},source_updated_at=#{value.sourceUpdatedAt}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java index f92a10783..6ff96dcc6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java @@ -9,14 +9,17 @@ import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryOperationEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryAttemptMapper; import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryHistoryMapper; import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryMapper; import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryOperationMapper; +import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleNodeFactMapper; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; import java.util.List; +import java.time.LocalDateTime; @Repository public class CarrierScheduleQueryRepository { @@ -24,12 +27,20 @@ public class CarrierScheduleQueryRepository { @Resource private CarrierScheduleQueryAttemptMapper attempts; @Resource private CarrierScheduleQueryHistoryMapper histories; @Resource private CarrierScheduleQueryOperationMapper operations; + @Resource private CarrierScheduleNodeFactMapper nodes; public PageResult page(Query query) { return PageResult.from(queries.selectPage(new Page<>(query.page(), query.size()), query)); } public CarrierScheduleQueryEntity find(long id) { return queries.selectById(id); } public CarrierScheduleQueryEntity lock(long id) { return queries.lockById(id); } + public CarrierScheduleQueryEntity findByBusinessScope(long customerId, long oceanBookingId, long containerBookingId) { + return queries.findByBusinessScope(customerId, oceanBookingId, containerBookingId); + } + public List due(LocalDateTime now, int limit) { return queries.selectDue(now, limit); } + public boolean claimLease(long id, String owner, LocalDateTime now, LocalDateTime leaseUntil) { + return queries.claimLease(id, owner, now, leaseUntil) == 1; + } public void insert(CarrierScheduleQueryEntity value) { queries.insert(value); } public int updateState(CarrierScheduleQueryEntity value, int version, long operatorId, String operatorName) { return queries.updateState(value, version, operatorId, operatorName); @@ -48,6 +59,14 @@ public class CarrierScheduleQueryRepository { .orderByAsc(CarrierScheduleQueryHistoryEntity::getId)); } public void insert(CarrierScheduleQueryHistoryEntity value) { histories.insert(value); } + public List nodes(long id) { + return nodes.selectList(new LambdaQueryWrapper() + .eq(CarrierScheduleNodeFactEntity::getQueryId, id) + .orderByAsc(CarrierScheduleNodeFactEntity::getNodeSequence) + .orderByAsc(CarrierScheduleNodeFactEntity::getObservedAt) + .orderByAsc(CarrierScheduleNodeFactEntity::getId)); + } + public boolean insertIfAbsent(CarrierScheduleNodeFactEntity value) { return nodes.insertIgnore(value) == 1; } public boolean claimOperation(Long queryId, long operatorId, String operation, String key, String hash) { return operations.claim(IdWorker.getId(), queryId, operatorId, operation, key, hash) == 1; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java index 1ddcd1d77..82e780b8e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java @@ -32,6 +32,14 @@ public class CarrierScheduleAdapterRegistry { return required(carrierCode).code(); } + public String resolve(String carrierName) { + String normalized = carrierName == null ? "" : carrierName.trim().toUpperCase(Locale.ROOT); + return DEFINITIONS.values().stream().filter(value -> value.matches(normalized)) + .findFirst().map(Definition::code) + .orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, + "出仓订舱船司无法映射至已支持的船节点供应商:" + carrierName)); + } + private static Definition required(String carrierCode) { String normalized = carrierCode == null ? "" : carrierCode.trim().toUpperCase(Locale.ROOT); Definition value = DEFINITIONS.get(normalized); @@ -79,6 +87,15 @@ public class CarrierScheduleAdapterRegistry { return new CarrierDescriptor(code, name, officialUrl, accessMode, false, true, coverageGapCode, manualReason); } + boolean matches(String value) { + return value.equals(code) || value.contains(code) || value.contains(name.toUpperCase(Locale.ROOT)) + || "MATSON".equals(code) && value.contains("美森") + || "SML".equals(code) && (value.contains("森罗") || value.contains("SM LINE")) + || "ZIM".equals(code) && value.contains("以星") + || "COSCO".equals(code) && (value.contains("中远") || value.contains("COSCO SHIPPING")) + || "EMC".equals(code) && (value.contains("长荣") || value.contains("EVERGREEN")) + || "HEDE".equals(code) && value.contains("合德"); + } } public record PreparedAttempt(String carrierCode, String accessMode, String officialUrl, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java new file mode 100644 index 000000000..d1e28ef83 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java @@ -0,0 +1,71 @@ +package com.baiyue.firstleg.portschedule.service; + +import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient; +import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient.QueryRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; +import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Component +public class CarrierScheduleQueryScheduler { + private final CarrierScheduleQueryRepository repository; + private final CarrierScheduleQueryService service; + private final CarrierScheduleIntegrationClient integration; + private final String workerId = UUID.randomUUID().toString(); + private Clock clock = Clock.systemDefaultZone(); + + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + @Value("${baiyue.carrier-schedule.scheduler.batch-size:20}") private int batchSize = 20; + @Value("${baiyue.carrier-schedule.scheduler.lease-duration:PT5M}") + private Duration leaseDuration = Duration.ofMinutes(5); + + public CarrierScheduleQueryScheduler(CarrierScheduleQueryRepository repository, + CarrierScheduleQueryService service, + CarrierScheduleIntegrationClient integration) { + this.repository = repository; this.service = service; this.integration = integration; + } + + @Scheduled(fixedDelayString = "${baiyue.carrier-schedule.scheduler.fixed-delay:PT1M}") + public void poll() { + LocalDateTime now = LocalDateTime.now(clock); + repository.due(now, Math.max(1, Math.min(batchSize, 100))).forEach(candidate -> execute(candidate, now)); + } + + /** Executes only after this worker wins the database lease. */ + void execute(CarrierScheduleQueryEntity candidate, LocalDateTime now) { + if (!repository.claimLease(candidate.getId(), workerId, now, now.plus(leaseDuration))) return; + try { + var response = integration.query(internalToken, + new QueryRequest(candidate.getCarrierCode(), candidate.getTrackingType(), candidate.getTrackingNo())); + if (response == null || !response.successful() || response.data() == null) { + service.completeScheduledAttempt(candidate.getId(), "RETRYABLE_FAILURE", "EMPTY_PROVIDER_RESPONSE", + "轨迹供应商返回空响应"); + return; + } + var result = response.data(); + List observations = result.observations() == null + ? List.of() : result.observations(); + for (var observation : observations) { + service.applyProviderObservation(candidate.getId(), new ProviderObservationRequest( + result.providerCode(), observation.providerEventId(), observation.providerNodeCode(), + observation.standardNode(), observation.observedAt(), observation.estimatedAt(), + observation.actualAt(), observation.locationCode(), observation.locationName(), + observation.sourceReference(), observation.rawPayloadHash())); + } + service.completeScheduledAttempt(candidate.getId(), result.outcome(), result.errorCode(), + result.errorMessage()); + } catch (RuntimeException exception) { + service.completeScheduledAttempt(candidate.getId(), "RETRYABLE_FAILURE", "PROVIDER_CALL_FAILED", + exception.getClass().getSimpleName()); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java index c5df3da71..adaa9a117 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java @@ -5,17 +5,22 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; -import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.AttemptRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView; -import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.NodeFact; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Task; +import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryOperationEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; +import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity; import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository; @@ -61,13 +66,148 @@ public class CarrierScheduleQueryService { public Detail detail(long id) { return detail(required(id)); } - public List history(long id) { + public List history(long id) { required(id); - return repository.histories(id); + return repository.histories(id).stream().map(this::history).toList(); } public long customerId(long id) { return required(id).getCustomerId(); } + /** Idempotently starts the canonical tracking task after domestic outbound confirmation. */ + @Transactional + public CarrierScheduleQueryEntity ensureFromOutboundConfirm(long customerId, long oceanBookingId, + long containerBookingId, + long operatorId, String operatorName) { + CarrierScheduleQueryEntity existing = repository.findByBusinessScope(customerId, oceanBookingId, + containerBookingId); + if (existing != null) return existing; + OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId); + ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId); + if (booking == null || container == null) throw new BizException(ResultCode.NOT_FOUND, "出仓订舱或柜次不存在"); + validateLinks(customerId, oceanBookingId, containerBookingId); + String carrierCode = adapters.resolve(booking.getShippingCompanyName()); + String trackingType; + String trackingNo; + if (StringUtils.hasText(container.getContainerNo())) { + trackingType = "CONTAINER_NO"; trackingNo = container.getContainerNo().trim(); + } else if (StringUtils.hasText(booking.getBillOfLadingNo())) { + trackingType = "BILL_OF_LADING"; trackingNo = booking.getBillOfLadingNo().trim(); + } else if (StringUtils.hasText(booking.getBookingNo())) { + trackingType = "BOOKING_NO"; trackingNo = booking.getBookingNo().trim(); + } else { + throw badRequest("确认国内出仓前必须具备柜号、提单号或订舱号之一"); + } + PreparedAttempt strategy = adapters.prepare(carrierCode, trackingType, trackingNo); + LocalDateTime now = now(); + CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); + value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId()); + value.setCustomerId(customerId); value.setOceanBookingId(oceanBookingId); + value.setContainerBookingId(containerBookingId); value.setCarrierCode(carrierCode); + value.setTrackingType(trackingType); value.setTrackingNo(trackingNo); + value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode()); + value.setStatus("READY"); value.setTriggerSource("OUTBOUND_CONFIRM"); + value.setLatestStandardNode(CarrierScheduleNode.DEPARTED_WAREHOUSE.name()); + value.setLatestNodeObservedAt(now); value.setWindowStartedAt(now); value.setAttemptCount(0); + value.setConsecutiveFailures(0); value.setNextAllowedAt(now); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); + value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); + try { repository.insert(value); } + catch (DuplicateKeyException exception) { + CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId, + containerBookingId); + if (concurrent != null) return concurrent; + throw exception; + } + addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM", + CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM", + sha256("OUTBOUND_CONFIRM|" + containerBookingId)); + addHistory(value.getId(), "AUTO_CREATE", null, 0, "国内确认出仓自动创建", null, value, + operatorId, operatorName); + return value; + } + + @Transactional + public Detail applyProviderObservation(long id, ProviderObservationRequest request) { + CarrierScheduleQueryEntity value = requiredLocked(id); + if ("STOPPED".equals(value.getStatus())) return detail(value); + CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); + CarrierScheduleQueryEntity before = copy(value); + if (!addNode(id, request.providerCode(), request.providerEventId(), request.providerNodeCode(), node, + request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(), + request.locationName(), request.sourceReference(), request.rawPayloadHash())) { + return detail(value); + } + CarrierScheduleNode current = value.getLatestStandardNode() == null ? null + : CarrierScheduleNode.require(value.getLatestStandardNode()); + if (current == null || node.sequence() >= current.sequence()) { + value.setLatestStandardNode(node.name()); value.setLatestNodeObservedAt(request.observedAt()); + value.setLastSuccessAt(now()); value.setSourceUpdatedAt(request.observedAt()); + value.setLastAttemptStatus("SUCCESS"); value.setLastErrorCode(null); value.setLastErrorMessage(null); + value.setStatus(node == CarrierScheduleNode.ARRIVED ? "ARRIVED" + : node == CarrierScheduleNode.PICKUP_READY ? "PICKUP_READY" : "READY"); + value.setNextAllowedAt(now().plusDays(WINDOW_DAYS)); + if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent(); + } + CarrierScheduleQueryEntity after = required(id); + addHistory(id, "NODE_OBSERVED", before.getVersion(), after.getVersion(), request.standardNode(), before, + after, 0L, "carrier-scheduler"); + return detail(after); + } + + @Transactional + public Detail stop(long id, String reason) { + CarrierScheduleQueryEntity value = requiredLocked(id); + if ("STOPPED".equals(value.getStatus())) return detail(value); + CarrierScheduleQueryEntity before = copy(value); + value.setStatus("STOPPED"); value.setStoppedAt(now()); value.setStopReason(trim(reason)); + value.setLeaseOwner(null); value.setLeaseUntil(null); value.setNextAllowedAt(null); + if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent(); + CarrierScheduleQueryEntity after = required(id); + addHistory(id, "STOP", before.getVersion(), after.getVersion(), reason, before, after, 0L, + "carrier-scheduler"); + return detail(after); + } + + @Transactional + public void completeScheduledAttempt(long id, String outcome, String errorCode, String errorMessage) { + CarrierScheduleQueryEntity value = requiredLocked(id); + if ("STOPPED".equals(value.getStatus())) return; + CarrierScheduleQueryEntity before = copy(value); + LocalDateTime now = now(); + value.setLeaseOwner(null); value.setLeaseUntil(null); value.setLastAttemptAt(now); + value.setLastAttemptStatus(outcome); value.setLastErrorCode(trim(errorCode)); + value.setLastErrorMessage(trim(errorMessage)); + if ("SUCCESS".equals(outcome) || "NO_CHANGE".equals(outcome)) { + value.setConsecutiveFailures(0); value.setAttemptCount(0); value.setWindowStartedAt(now); + value.setNextAllowedAt(now.plusDays(WINDOW_DAYS)); + } else { + int failures = count(value.getConsecutiveFailures()) + 1; + LocalDateTime windowStart = value.getWindowStartedAt(); + if (windowStart == null || !now.isBefore(windowStart.plusDays(WINDOW_DAYS))) { + windowStart = now; value.setAttemptCount(0); + } + int attempts = count(value.getAttemptCount()) + 1; + value.setWindowStartedAt(windowStart); value.setAttemptCount(Math.min(MAX_ATTEMPTS, attempts)); + value.setConsecutiveFailures(failures); + if ("MANUAL_REQUIRED".equals(outcome) || "PERMANENT_FAILURE".equals(outcome)) { + if (!"ARRIVED".equals(value.getStatus()) && !"PICKUP_READY".equals(value.getStatus())) { + value.setStatus("MANUAL_REQUIRED"); + } + value.setNextAllowedAt(now.plusDays(WINDOW_DAYS)); + } else { + long minutes = Math.min(24L * 60L, 15L << Math.min(failures - 1, 6)); + LocalDateTime retryAt = now.plusMinutes(minutes); + if (attempts >= MAX_ATTEMPTS && retryAt.isBefore(windowStart.plusDays(WINDOW_DAYS))) { + retryAt = windowStart.plusDays(WINDOW_DAYS); + } + value.setNextAllowedAt(retryAt); + } + } + if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent(); + CarrierScheduleQueryEntity after = required(id); + addHistory(id, "PROVIDER_QUERY", before.getVersion(), after.getVersion(), outcome, before, after, 0L, + "carrier-scheduler"); + } + @Transactional public Detail create(long operatorId, String operatorName, String key, CreateRequest request) { String operation = "CREATE"; @@ -84,7 +224,8 @@ public class CarrierScheduleQueryService { value.setContainerBookingId(request.containerBookingId()); value.setCarrierCode(carrierCode); value.setTrackingType(trackingType); value.setTrackingNo(request.trackingNo().trim()); value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode()); - value.setStatus("READY"); value.setWindowStartedAt(now); value.setAttemptCount(0); + value.setStatus("READY"); value.setTriggerSource("MANUAL"); + value.setWindowStartedAt(now); value.setAttemptCount(0); value.setConsecutiveFailures(0); value.setNextAllowedAt(now); value.setManualReason(strategy.manualReason()); value.setRemark(trim(request.remark())); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); @@ -94,51 +235,6 @@ public class CarrierScheduleQueryService { return complete(value.getId(), operatorId, key, detail(value)); } - @Transactional - public Detail attempt(long id, long operatorId, String operatorName, String key, AttemptRequest request) { - String operation = "ATTEMPT"; - Detail replay = claimOrReplay(id, operatorId, operation, key, hash(operation, id, request)); - if (replay != null) return replay; - CarrierScheduleQueryEntity before = requiredLockedVersion(id, request.expectedVersion()); - LocalDateTime now = now(); - LocalDateTime windowStart = before.getWindowStartedAt(); - if (windowStart == null || !now.isBefore(windowStart.plusDays(WINDOW_DAYS))) { - windowStart = now; - before.setAttemptCount(0); - } - if (before.getLastSuccessAt() != null && before.getNextAllowedAt() != null - && now.isBefore(before.getNextAllowedAt())) { - throw conflict("船期查询每3天执行一次,下次允许时间:" + before.getNextAllowedAt()); - } - int attemptCount = count(before.getAttemptCount()); - LocalDateTime windowEnd = windowStart.plusDays(WINDOW_DAYS); - if (attemptCount >= MAX_ATTEMPTS && now.isBefore(windowEnd)) { - throw conflict("当前3天窗口最多查询3次,下次允许时间:" + windowEnd); - } - PreparedAttempt strategy = adapters.prepare(before.getCarrierCode(), before.getTrackingType(), before.getTrackingNo()); - CarrierScheduleQueryEntity snapshot = copy(before); - int nextCount = attemptCount + 1; - before.setOfficialUrl(strategy.officialUrl()); before.setAccessMode(strategy.accessMode()); - before.setStatus(strategy.status()); before.setWindowStartedAt(windowStart); before.setAttemptCount(nextCount); - before.setLastAttemptAt(now); before.setLastAttemptStatus(strategy.status()); - before.setManualReason(strategy.manualReason()); before.setLastErrorCode(strategy.coverageGapCode()); - before.setLastErrorMessage(strategy.manualReason()); - before.setNextAllowedAt(nextCount >= MAX_ATTEMPTS ? windowEnd : now); - if (repository.updateState(before, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent(); - CarrierScheduleQueryEntity after = required(id); - CarrierScheduleQueryAttemptEntity attempt = new CarrierScheduleQueryAttemptEntity(); - attempt.setId(IdWorker.getId()); attempt.setQueryId(id); attempt.setWindowAttemptNo(nextCount); - attempt.setCarrierCode(after.getCarrierCode()); attempt.setAccessMode(strategy.accessMode()); - attempt.setOfficialUrl(strategy.officialUrl()); attempt.setAttemptStatus(strategy.status()); - attempt.setCoverageGap(true); attempt.setCoverageGapCode(strategy.coverageGapCode()); - attempt.setManualReason(strategy.manualReason()); attempt.setErrorCode(strategy.coverageGapCode()); - attempt.setErrorMessage(strategy.manualReason()); attempt.setOperatorId(operatorId); attempt.setOperatorName(operatorName); - repository.insert(attempt); - addHistory(id, operation, snapshot.getVersion(), after.getVersion(), request.reason(), snapshot, after, - operatorId, operatorName); - return complete(id, operatorId, key, detail(after)); - } - @Transactional public Detail attachManualResult(long id, long operatorId, String operatorName, String key, ManualResultRequest request) { @@ -155,7 +251,7 @@ public class CarrierScheduleQueryService { } CarrierScheduleQueryEntity before = copy(value); LocalDateTime now = now(); - value.setStatus("SUCCESS"); value.setResultPortScheduleId(schedule.getId()); + value.setStatus("READY"); value.setResultPortScheduleId(schedule.getId()); value.setLastSuccessAt(now); value.setSourceUpdatedAt(schedule.getObservedAt()); value.setExpiresAt(schedule.getExpiresAt()); value.setNextAllowedAt(now.plusDays(WINDOW_DAYS)); value.setWindowStartedAt(now); value.setLastAttemptStatus("MANUAL_COMPLETED"); @@ -212,13 +308,48 @@ public class CarrierScheduleQueryService { } private Detail detail(CarrierScheduleQueryEntity value) { - return new Detail(view(value, now()), repository.attempts(value.getId()), repository.histories(value.getId())); + return new Detail(view(value, now()), repository.nodes(value.getId()).stream().map(this::node).toList(), + repository.histories(value.getId()).stream().map(this::history).toList()); + } + + private NodeFact node(CarrierScheduleNodeFactEntity value) { + return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderNodeCode(), + value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(), + value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(), + value.getRawPayloadHash(), value.getCreatedAt()); + } + + private History history(CarrierScheduleQueryHistoryEntity value) { + return new History(value.getId(), value.getAction(), value.getFromVersion(), value.getToVersion(), + value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt()); + } + + private boolean addNode(long queryId, String providerCode, String providerEventId, String providerNodeCode, + CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt, + LocalDateTime actualAt, String locationCode, String locationName, + String sourceReference, String rawPayloadHash) { + CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity(); + fact.setId(IdWorker.getId()); fact.setQueryId(queryId); + fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim()); + fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT)); + fact.setProviderNodeCode(providerNodeCode.trim()); fact.setStandardNode(node.name()); + fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt); + fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName)); + fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash); + return repository.insertIfAbsent(fact); } private static QueryView view(CarrierScheduleQueryEntity value, LocalDateTime now) { boolean expired = value.getLastSuccessAt() == null || value.getExpiresAt() == null || !value.getExpiresAt().isAfter(now); - return new QueryView(value, expired, MAX_ATTEMPTS, value.getNextAllowedAt()); + Task task = new Task(value.getId(), value.getQueryNo(), value.getCustomerId(), value.getOceanBookingId(), + value.getContainerBookingId(), value.getCarrierCode(), value.getTrackingType(), value.getTrackingNo(), + value.getOfficialUrl(), value.getAccessMode(), value.getStatus(), value.getTriggerSource(), + value.getLatestStandardNode(), value.getLatestNodeObservedAt(), value.getLastSuccessAt(), + value.getSourceUpdatedAt(), value.getExpiresAt(), value.getResultPortScheduleId(), value.getManualReason(), + value.getLastErrorCode(), value.getLastErrorMessage(), value.getStoppedAt(), value.getStopReason(), + value.getVersion()); + return new QueryView(task, expired); } private CarrierScheduleQueryEntity required(long id) { @@ -234,6 +365,12 @@ public class CarrierScheduleQueryService { return value; } + private CarrierScheduleQueryEntity requiredLocked(long id) { + CarrierScheduleQueryEntity value = repository.lock(id); + if (value == null) throw new BizException(ResultCode.NOT_FOUND, "船司查询任务不存在"); + return value; + } + private static CarrierScheduleQueryEntity copy(CarrierScheduleQueryEntity source) { CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); value.setId(source.getId()); value.setQueryNo(source.getQueryNo()); value.setCustomerId(source.getCustomerId()); @@ -241,6 +378,10 @@ public class CarrierScheduleQueryService { value.setCarrierCode(source.getCarrierCode()); value.setTrackingType(source.getTrackingType()); value.setTrackingNo(source.getTrackingNo()); value.setOfficialUrl(source.getOfficialUrl()); value.setAccessMode(source.getAccessMode()); value.setStatus(source.getStatus()); + value.setTriggerSource(source.getTriggerSource()); value.setLatestStandardNode(source.getLatestStandardNode()); + value.setLatestNodeObservedAt(source.getLatestNodeObservedAt()); value.setStoppedAt(source.getStoppedAt()); + value.setStopReason(source.getStopReason()); value.setLeaseOwner(source.getLeaseOwner()); + value.setLeaseUntil(source.getLeaseUntil()); value.setConsecutiveFailures(source.getConsecutiveFailures()); value.setWindowStartedAt(source.getWindowStartedAt()); value.setAttemptCount(source.getAttemptCount()); value.setNextAllowedAt(source.getNextAllowedAt()); value.setLastAttemptAt(source.getLastAttemptAt()); value.setLastSuccessAt(source.getLastSuccessAt()); value.setSourceUpdatedAt(source.getSourceUpdatedAt()); @@ -259,6 +400,12 @@ public class CarrierScheduleQueryService { } catch (Exception ex) { throw new IllegalStateException("无法计算船司查询请求摘要", ex); } } + private static String sha256(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw new IllegalStateException(exception); } + } + private String json(Object value) { if (value == null) return null; try { return objectMapper.writeValueAsString(value); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java index a82cffec0..cb8935edd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java @@ -15,17 +15,27 @@ import java.util.List; public class LoadingCompletedOutboxRepository { @Resource private InboundOutboxMapper mapper; - public List pending(int limit, LocalDateTime now) { + public List pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) { return mapper.selectList(new LambdaQueryWrapper() .eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) - .eq(InboundOutboxEntity::getPublishStatus, "PENDING") - .and(v -> v.isNull(InboundOutboxEntity::getNextRetryAt).or().le(InboundOutboxEntity::getNextRetryAt, now)) + .and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING") + .and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt) + .or().le(InboundOutboxEntity::getNextRetryAt, now)) + .or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING") + .le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore))) .orderByAsc(InboundOutboxEntity::getCreatedAt).orderByAsc(InboundOutboxEntity::getId) .last("LIMIT " + Math.min(Math.max(limit, 1), 100))); } - public boolean claim(long id, String worker, LocalDateTime now) { return mapper.update(null, base(id, "PENDING") + public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(InboundOutboxEntity::getId, id) + .eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) + .and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING") + .or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING") + .le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore))) .set(InboundOutboxEntity::getPublishStatus, "PROCESSING").set(InboundOutboxEntity::getLockedBy, worker) - .set(InboundOutboxEntity::getLockedAt, now)) == 1; } + .set(InboundOutboxEntity::getLockedAt, now)) == 1; + } public InboundOutboxEntity claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper() .eq(InboundOutboxEntity::getId, id).eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) .eq(InboundOutboxEntity::getPublishStatus, "PROCESSING").eq(InboundOutboxEntity::getLockedBy, worker)); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java index 2733ac4a8..612018986 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java @@ -44,7 +44,14 @@ public class ContainerOverviewService { List timeline = timeline(id, task == null ? null : task.id(), exceptionHistory); return new Overview(new ContainerBookingEntityView(booking.getId(), booking.getBookingNo(), booking.getCabinetNo(), booking.getContainerNo(), booking.getPlanStatus(), booking.getWarehouseId(), - booking.getWarehouseName(), booking.getVoyageNo(), booking.getVesselName(), booking.getVersion()), + booking.getWarehouseName(), booking.getVoyageNo(), booking.getVesselName(), booking.getBillOfLadingNo(), + booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getDeparturePortCode(), + booking.getDeparturePortName(), booking.getDestinationPortCode(), booking.getDestinationPortName(), + booking.getEstimatedSailingAt(), booking.getActualSailingAt(), booking.getEstimatedPortArrivalAt(), + booking.getActualPortArrivalAt(), booking.getEstimatedWarehouseArrivalAt(), + booking.getActualWarehouseArrivalAt(), booking.getUnloadingCompletedAt(), + booking.getUnloadingPhotoUploadedAt(), booking.getEstimatedEmptyReportedAt(), + booking.getActualEmptyReportedAt(), booking.getEmptyReportSourceVersion(), booking.getVersion()), task == null ? booking.getPlanId() : task.loadingPlanId(), pda, documents, new ExceptionState(latestException != null && "MARK".equals(latestException.action()), latestException == null ? null : latestException.reason(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java index bc3052d4a..30bf94202 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java @@ -22,13 +22,16 @@ public class LoadingCompletedOutboxRelay { @Resource private EventBridgeClient client; @Resource private ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token}") private String internalToken; + @Value("${baiyue.lastleg.outbox-relay.lease-seconds:60}") private long leaseSeconds; + @Value("${baiyue.lastleg.outbox-relay.retry-seconds:30}") private long retrySeconds; private final String workerId = UUID.randomUUID().toString(); @Scheduled(fixedDelayString = "${baiyue.lastleg.outbox-relay.fixed-delay-ms:5000}") public void relay() { LocalDateTime now=LocalDateTime.now(); - for (InboundOutboxEntity candidate:repository.pending(50,now)) { - if(!repository.claim(candidate.getId(),workerId,now)) continue; + LocalDateTime expiredLeaseBefore=now.minusSeconds(Math.max(leaseSeconds,10)); + for (InboundOutboxEntity candidate:repository.pending(50,now,expiredLeaseBefore)) { + if(!repository.claim(candidate.getId(),workerId,now,expiredLeaseBefore)) continue; InboundOutboxEntity row=repository.claimed(candidate.getId(),workerId); if(row==null) continue; try { @@ -39,9 +42,18 @@ public class LoadingCompletedOutboxRelay { || !event.eventId().equals(response.data().eventId())) throw new IllegalStateException("事件桥未确认装柜完成事件"); repository.published(row.getId(),workerId,LocalDateTime.now()); } catch(Exception exception) { - repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(30),exception.getMessage()); - log.warn("装柜完成事件投递失败: eventKey={}, reason={}",row.getEventKey(),exception.getMessage()); + String error=safeError(exception); + repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(Math.max(retrySeconds,1)),error); + log.warn("装柜完成事件投递失败: eventKey={}, reason={}",row.getEventKey(),error); } } } + + private String safeError(Exception exception) { + String value=exception.getMessage(); + if(value==null || value.isBlank()) value=exception.getClass().getSimpleName(); + if(internalToken!=null && !internalToken.isBlank()) value=value.replace(internalToken,"***"); + value=value.replaceAll("[\\r\\n\\t]+"," "); + return value.length()>500?value.substring(0,500):value; + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java index 67748dd32..1873105dd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java @@ -30,9 +30,12 @@ public class OverseasContainerEventConsumer { public ContainerBookingEntity consume(EventEnvelope event) { validateEnvelope(event); OverseasContainerUnloadedV1 payload = payload(event); - if (payload.containerNo() == null || payload.containerNo().isBlank() - || !payload.containerNo().equals(event.aggregateId())) { - throw new IllegalArgumentException("事件 aggregateId 与 containerNo 不一致"); + if (payload.sourceContainerBookingId() == null || payload.sourceContainerBookingId().isBlank() + || !payload.sourceContainerBookingId().equals(event.aggregateId())) { + throw new IllegalArgumentException("事件 aggregateId 与 sourceContainerBookingId 不一致"); + } + if (payload.containerNo() == null || payload.containerNo().isBlank()) { + throw new IllegalArgumentException("货柜号不能为空"); } if (payload.unloadingCompletedAt() == null || payload.unloadingPhotoUploadedAt() == null) { throw new IllegalArgumentException("卸柜完成时间和卸柜照片上传时间不能为空"); @@ -47,7 +50,7 @@ public class OverseasContainerEventConsumer { if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) { throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突"); } - return requiredContainer(payload.containerNo()); + return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); } ContainerEventInboxEntity row = inbox(event); try { @@ -55,17 +58,17 @@ public class OverseasContainerEventConsumer { } catch (DuplicateKeyException duplicate) { ContainerEventInboxEntity raced = inbox.selectById(event.eventId()); if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate; - return requiredContainer(payload.containerNo()); + return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); } - ContainerBookingEntity container = requiredContainer(payload.containerNo()); + ContainerBookingEntity container = requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); LocalDateTime uploadedAt = utc(payload.unloadingPhotoUploadedAt()); containers.applyEmptyReport(container.getId(), utc(payload.unloadingCompletedAt()), uploadedAt, uploadedAt.plusDays(2), utc(payload.actualEmptyReportedAt()), event.aggregateVersion()); row.setStatus("PROCESSED"); row.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC)); inbox.updateById(row); - return requiredContainer(payload.containerNo()); + return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); } private void validateEnvelope(EventEnvelope event) { @@ -97,9 +100,18 @@ public class OverseasContainerEventConsumer { return row; } - private ContainerBookingEntity requiredContainer(String containerNo) { - ContainerBookingEntity value = containers.selectActiveByContainerNo(containerNo); + private ContainerBookingEntity requiredContainer(String sourceContainerBookingId, String containerNo) { + long bookingId; + try { + bookingId = Long.parseLong(sourceContainerBookingId); + } catch (NumberFormatException exception) { + throw new IllegalArgumentException("国内货柜订舱 ID 无效", exception); + } + ContainerBookingEntity value = containers.selectActiveBookingForUpdate(bookingId); if (value == null) throw new IllegalArgumentException("头程货柜不存在或不可用"); + if (value.getContainerNo() == null || !value.getContainerNo().equals(containerNo)) { + throw new IllegalArgumentException("事件货柜号与国内订舱记录不一致"); + } return value; } diff --git a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql index 80636650e..5b58c4d67 100644 --- a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql +++ b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql @@ -13,10 +13,18 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码', official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照', access_mode VARCHAR(32) NOT NULL COMMENT '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面', - status VARCHAR(24) NOT NULL COMMENT '任务状态:READY可查询、MANUAL_REQUIRED需人工处理、SUCCESS已取得有效结果', + status VARCHAR(24) NOT NULL COMMENT '任务状态:READY、MANUAL_REQUIRED、ARRIVED、PICKUP_READY、STOPPED', + trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL', + latest_standard_node VARCHAR(32) NULL COMMENT '最近标准船节点', + latest_node_observed_at DATETIME(3) NULL COMMENT '最近标准节点观测时间', + stopped_at DATETIME(3) NULL COMMENT '任务停止时间', + stop_reason VARCHAR(500) NULL COMMENT '任务停止原因', + lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者', + lease_until DATETIME(3) NULL COMMENT '当前调度租约过期时间', + consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败次数,用于指数退避', window_started_at DATETIME(3) NOT NULL COMMENT '当前三天查询窗口起始时间', attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前查询窗口已执行次数;最多3次', - next_allowed_at DATETIME(3) NOT NULL COMMENT '频控规则允许的下次查询时间', + next_allowed_at DATETIME(3) NULL COMMENT '频控规则允许的下次查询时间;停止后为空', last_attempt_at DATETIME(3) NULL COMMENT '最近一次查询尝试时间', last_success_at DATETIME(3) NULL COMMENT '最近一次取得有效结果时间', source_updated_at DATETIME(3) NULL COMMENT '船公司官网展示的结果更新时间', @@ -37,7 +45,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( PRIMARY KEY (id), UNIQUE KEY uk_carrier_schedule_query_no (query_no), UNIQUE KEY uk_carrier_schedule_query_scope - (customer_id,ocean_booking_id,container_scope_id,carrier_code,tracking_type,tracking_no), + (customer_id,ocean_booking_id,container_scope_id), KEY idx_carrier_schedule_query_due (status,next_allowed_at,id), KEY idx_carrier_schedule_query_customer (customer_id,updated_at,id), KEY idx_carrier_schedule_query_expiry (expires_at,id), @@ -52,7 +60,10 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( CONSTRAINT chk_carrier_schedule_query_access CHECK (access_mode IN ('PUBLIC_FORM','HCAPTCHA','DYNAMIC_IFRAME')), CONSTRAINT chk_carrier_schedule_query_status CHECK - (status IN ('READY','MANUAL_REQUIRED','SUCCESS')), + (status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY','STOPPED')), + CONSTRAINT chk_carrier_schedule_query_node CHECK + (latest_standard_node IS NULL OR latest_standard_node IN + ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')), CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3) ) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控'; @@ -95,9 +106,35 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history ( PRIMARY KEY (id), KEY idx_carrier_schedule_history_query (query_id,created_at,id), CONSTRAINT fk_carrier_schedule_history_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), - CONSTRAINT chk_carrier_schedule_history_action CHECK (action IN ('CREATE','ATTEMPT','MANUAL_RESULT')) + CONSTRAINT chk_carrier_schedule_history_action CHECK + (action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','NODE_OBSERVED','PROVIDER_QUERY','STOP')) ) ENGINE=InnoDB COMMENT='船司查询任务不可变历史'; +CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( + id BIGINT UNSIGNED NOT NULL COMMENT '标准船节点事实ID', + query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID', + event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', + provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码', + provider_node_code VARCHAR(64) NOT NULL COMMENT '供应商原始节点编码', + standard_node VARCHAR(32) NOT NULL COMMENT '标准船节点', + node_sequence INT UNSIGNED NOT NULL COMMENT '标准节点单调序号', + observed_at DATETIME(3) NOT NULL COMMENT '供应商观测时间', + estimated_at DATETIME(3) NULL COMMENT '供应商预计发生时间', + actual_at DATETIME(3) NULL COMMENT '供应商确认实际发生时间', + location_code VARCHAR(32) NULL COMMENT '节点地点编码', + location_name VARCHAR(128) NULL COMMENT '节点地点名称', + source_reference VARCHAR(500) NULL COMMENT '可审计来源引用', + raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', + PRIMARY KEY (id), + UNIQUE KEY uk_carrier_schedule_node_event (query_id,event_key), + KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id), + CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), + CONSTRAINT chk_carrier_schedule_node_standard CHECK + (standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')), + CONSTRAINT chk_carrier_schedule_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60)) +) ENGINE=InnoDB COMMENT='供应商标准船节点不可变事实'; + CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation ( id BIGINT UNSIGNED NOT NULL COMMENT '船司查询写操作幂等记录ID', query_id BIGINT UNSIGNED NULL COMMENT '关联查询任务ID;创建任务落库前允许为空', @@ -138,6 +175,8 @@ DROP TRIGGER IF EXISTS trg_carrier_schedule_attempt_before_update; DROP TRIGGER IF EXISTS trg_carrier_schedule_attempt_before_delete; DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_update; DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_delete; +DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_update; +DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_delete; DELIMITER $$ CREATE TRIGGER trg_carrier_schedule_attempt_before_update BEFORE UPDATE ON fl_carrier_schedule_query_attempt FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询尝试不可修改'; END$$ @@ -147,15 +186,21 @@ CREATE TRIGGER trg_carrier_schedule_history_before_update BEFORE UPDATE ON fl_ca FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史不可修改'; END$$ CREATE TRIGGER trg_carrier_schedule_history_before_delete BEFORE DELETE ON fl_carrier_schedule_query_history FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史不可删除'; END$$ +CREATE TRIGGER trg_carrier_schedule_node_before_update BEFORE UPDATE ON fl_carrier_schedule_node_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可修改'; END$$ +CREATE TRIGGER trg_carrier_schedule_node_before_delete BEFORE DELETE ON fl_carrier_schedule_node_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可删除'; END$$ DELIMITER ; USE baiyue_iam; DELETE role_permission FROM iam_role_permission role_permission JOIN iam_permission permission ON permission.id=role_permission.permission_id WHERE permission.tenant_id=0 - AND permission.permission_code='platform:firstleg:port-schedule-query:view'; + AND permission.permission_code IN ('platform:firstleg:port-schedule-query:view', + 'platform:firstleg:port-schedule-query:attempt'); DELETE FROM iam_permission -WHERE tenant_id=0 AND permission_code='platform:firstleg:port-schedule-query:view'; +WHERE tenant_id=0 AND permission_code IN ('platform:firstleg:port-schedule-query:view', + 'platform:firstleg:port-schedule-query:attempt'); DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_permission_ids; DELIMITER $$ @@ -163,16 +208,15 @@ CREATE PROCEDURE assert_carrier_schedule_query_permission_ids() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id IN (5102184,5102186,5102187,5102188) + WHERE (id IN (5102184,5102186,5102188) AND permission_code <> CASE id WHEN 5102184 THEN 'platform:firstleg:port-schedule-query:list' WHEN 5102186 THEN 'platform:firstleg:port-schedule-query:create' - WHEN 5102187 THEN 'platform:firstleg:port-schedule-query:attempt' WHEN 5102188 THEN 'platform:firstleg:port-schedule-query:manual' END) - OR id=5102185 + OR id IN (5102185,5102187) OR (permission_code LIKE 'platform:firstleg:port-schedule-query:%' - AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT IN (5102184,5102186,5102187,5102188))) + AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT IN (5102184,5102186,5102188))) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='三期船司查询权限迁移失败:5102184-5102188 ID或权限码被占用'; @@ -188,7 +232,6 @@ INSERT INTO iam_permission VALUES (5102184,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:list','查询船司任务',NULL,'GET',40,'ACTIVE',1,0), (5102186,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:create','创建船司任务',NULL,'POST',60,'ACTIVE',1,0), - (5102187,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:attempt','执行船司查询',NULL,'POST',70,'ACTIVE',1,0), (5102188,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:manual','录入人工结果',NULL,'POST',80,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type), permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id), @@ -209,12 +252,13 @@ BEGIN IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_firstleg' AND table_name IN ('fl_carrier_schedule_query','fl_carrier_schedule_query_attempt', - 'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation')) <> 4 THEN + 'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation', + 'fl_carrier_schedule_node_fact')) <> 5 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务表未完整创建'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission - WHERE tenant_id=0 AND id BETWEEN 5102184 AND 5102188 - AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 4 THEN + WHERE tenant_id=0 AND id IN (5102184,5102186,5102188) + AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务权限未完整创建'; END IF; IF (SELECT COUNT(*) @@ -223,7 +267,7 @@ BEGIN JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') AND permission.tenant_id=0 - AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 8 THEN + AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 6 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务角色授权未完整创建'; END IF; END$$ diff --git a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql index d45265ad9..6512d8b88 100644 --- a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql +++ b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql @@ -232,6 +232,8 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱状态历史不可删除'; DELIMITER ; USE baiyue_iam; +DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102141,5102143,5102144,5102145); +DELETE FROM iam_permission WHERE id IN (5102141,5102143,5102144,5102145); DROP PROCEDURE IF EXISTS assert_ocean_booking_permission_ids_available; DELIMITER $$ CREATE PROCEDURE assert_ocean_booking_permission_ids_available() @@ -239,23 +241,14 @@ BEGIN IF EXISTS ( SELECT 1 FROM iam_permission WHERE (id = 5102140 AND permission_code <> 'platform:firstleg:ocean-booking:list') - OR (id = 5102141 AND permission_code NOT IN ( - 'platform:firstleg:ocean-booking:list', - 'platform:firstleg:ocean-booking:view')) OR (id = 5102142 AND permission_code <> 'platform:firstleg:ocean-booking:create') - OR (id = 5102143 AND permission_code <> 'platform:firstleg:ocean-booking:edit') - OR (id = 5102144 AND permission_code <> 'platform:firstleg:ocean-booking:submit') - OR (id = 5102145 AND permission_code <> 'platform:firstleg:ocean-booking:confirm') OR (id = 5102146 AND permission_code <> 'platform:firstleg:ocean-booking:release') - OR (id = 5102147 AND permission_code NOT IN ( - 'platform:firstleg:ocean-booking:cancel', - 'platform:firstleg:ocean-booking:edit')) - OR (id = 5102148 AND permission_code NOT IN ( - 'platform:firstleg:ocean-booking:change', - 'platform:firstleg:ocean-booking:delete')) + OR (id = 5102147 AND permission_code <> 'platform:firstleg:ocean-booking:edit') + OR (id = 5102148 AND permission_code <> 'platform:firstleg:ocean-booking:delete') OR (id = 5102149 AND permission_code <> 'platform:firstleg:ocean-booking:allocate') OR (permission_code LIKE 'platform:firstleg:ocean-booking:%' - AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102140 AND 5102149)) + AND (tenant_id <> 0 OR app_code <> 'PLATFORM' + OR id NOT IN (5102140,5102142,5102146,5102147,5102148,5102149))) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '三期订舱权限迁移失败:5102140-5102149 ID 或权限码被其他语义占用'; @@ -265,13 +258,6 @@ DELIMITER ; CALL assert_ocean_booking_permission_ids_available(); DROP PROCEDURE IF EXISTS assert_ocean_booking_permission_ids_available; -UPDATE iam_permission - SET permission_code='platform:firstleg:ocean-booking:view',status='INACTIVE',deleted=1 - WHERE id=5102141 AND permission_code='platform:firstleg:ocean-booking:list'; - -DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102143,5102144,5102145); -DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145); - INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name, route_path,http_method,sort_no,status,built_in,deleted) @@ -291,8 +277,4 @@ SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.permission_code LIKE 'platform:firstleg:ocean-booking:%' WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR'); -DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id -WHERE p.id=5102141; -UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102141; - USE baiyue_firstleg; diff --git a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql index efc2bab89..8572d80c8 100644 --- a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql +++ b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql @@ -112,7 +112,7 @@ BEGIN WHERE (id = 5102150 AND permission_code <> 'platform:firstleg:truck-appointment:list') OR (id = 5102151 AND permission_code NOT IN ( 'platform:firstleg:truck-appointment:list', - 'platform:firstleg:truck-appointment:view')) + CONCAT('platform:firstleg:truck-appointment:', 'view'))) OR (id = 5102152 AND permission_code <> 'platform:firstleg:truck-appointment:create') OR (id = 5102153 AND permission_code <> 'platform:firstleg:truck-appointment:edit') OR (id = 5102154 AND permission_code <> 'platform:firstleg:truck-appointment:operate') @@ -127,10 +127,6 @@ DELIMITER ; CALL assert_truck_appointment_permission_ids_available(); DROP PROCEDURE IF EXISTS assert_truck_appointment_permission_ids_available; -UPDATE iam_permission - SET permission_code='platform:firstleg:truck-appointment:view',status='INACTIVE',deleted=1 - WHERE id=5102151 AND permission_code='platform:firstleg:truck-appointment:list'; - INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name, route_path,http_method,sort_no,status,built_in,deleted) @@ -151,6 +147,6 @@ WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id WHERE p.id=5102151; -UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102151; +DELETE FROM iam_permission WHERE id=5102151; USE baiyue_firstleg; diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-internal-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-internal-v1.yaml new file mode 100644 index 000000000..30ecf0cd5 --- /dev/null +++ b/firstleg/src/main/resources/openapi/carrier-schedule-internal-v1.yaml @@ -0,0 +1,23 @@ +openapi: 3.0.3 +info: + title: Carrier Schedule Internal Handoff API + version: 1.0.0 + description: Internal-only handoff contract; it is not a platform or merchant frontend API. +paths: + /internal/firstleg/v1/carrier-schedule-queries/{queryId}/stop: + post: + summary: Stop tracking after the overseas pickup handoff has completed + parameters: + - {name: queryId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + - {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [reason] + properties: + reason: {type: string, minLength: 1, maxLength: 500} + responses: + '200': {description: Idempotent STOPPED task detail} diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index e63d175fa..46a1b3371 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: Carrier Schedule Query API version: 1.0.0 - description: Six-carrier query tasks with a three-day window, at most three attempts, and explicit manual fallback. + description: Phase-4 carrier tracking from domestic outbound confirmation through destination pickup readiness. paths: /api/platform/v1/carrier-schedule-queries/carriers: get: @@ -40,7 +40,7 @@ paths: /api/platform/v1/carrier-schedule-queries/{queryId}: parameters: [{$ref: '#/components/parameters/QueryId'}] get: - summary: Get task, immutable attempts and history + summary: Get task, standardized node facts and immutable business history x-permission: platform:firstleg:port-schedule-query:list responses: '200': {description: Stable task detail} @@ -51,19 +51,6 @@ paths: x-permission: platform:firstleg:port-schedule-query:list responses: '200': {description: CREATE, ATTEMPT and MANUAL_RESULT history} - /api/platform/v1/carrier-schedule-queries/{queryId}/attempts: - parameters: [{$ref: '#/components/parameters/QueryId'}] - post: - summary: Execute the compliant carrier strategy; never bypass captcha or dynamic pages - x-permission: platform:firstleg:port-schedule-query:attempt - parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] - requestBody: - required: true - content: - application/json: - schema: {$ref: '#/components/schemas/AttemptRequest'} - responses: - '200': {description: MANUAL_REQUIRED with coverageGap when no public structured API is available} /api/platform/v1/carrier-schedule-queries/{queryId}/manual-results: parameters: [{$ref: '#/components/parameters/QueryId'}] post: @@ -84,7 +71,8 @@ components: schemas: CarrierCode: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]} TrackingType: {type: string, enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]} - QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, SUCCESS]} + QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, ARRIVED, PICKUP_READY, STOPPED]} + StandardNode: {type: string, enum: [DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY]} CreateRequest: type: object required: [expectedVersion, customerId, oceanBookingId, carrierCode, trackingType, trackingNo] @@ -97,12 +85,6 @@ components: trackingType: {$ref: '#/components/schemas/TrackingType'} trackingNo: {type: string, minLength: 1, maxLength: 128} remark: {type: string, nullable: true, maxLength: 1000} - AttemptRequest: - type: object - required: [expectedVersion] - properties: - expectedVersion: {type: integer, minimum: 0} - reason: {type: string, nullable: true, maxLength: 1000} ManualResultRequest: type: object required: [expectedVersion, portScheduleId, reason] @@ -112,9 +94,7 @@ components: reason: {type: string, minLength: 1, maxLength: 1000} QueryView: type: object - required: [query, expired, maxAttempts, nextAllowedAt] + required: [query, expired] properties: - query: {type: object, description: Includes source, attemptCount, lastAttemptAt, lastSuccessAt, sourceUpdatedAt and manualReason} + query: {type: object, description: Includes triggerSource, latestStandardNode, latestNodeObservedAt, source freshness and stop fields; scheduler leases and raw attempts are never exposed} expired: {type: boolean} - maxAttempts: {type: integer, enum: [3]} - nextAllowedAt: {type: string, format: date-time} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerOverviewTransportContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerOverviewTransportContractTest.java new file mode 100644 index 000000000..1d562c4ec --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerOverviewTransportContractTest.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.firstleg.dto.ContainerOverviewDtos.ContainerBookingEntityView; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class ContainerOverviewTransportContractTest { + @Test + void overviewPublishesMaritimeAndEmptyReportFacts() { + assertThat(Arrays.stream(ContainerBookingEntityView.class.getRecordComponents()).map(value -> value.getName())) + .contains("billOfLadingNo", "shippingCompanyId", "shippingCompanyName", "departurePortCode", + "departurePortName", "destinationPortCode", "destinationPortName", "estimatedSailingAt", + "actualSailingAt", "estimatedPortArrivalAt", "actualPortArrivalAt", + "estimatedWarehouseArrivalAt", "actualWarehouseArrivalAt", "unloadingCompletedAt", + "unloadingPhotoUploadedAt", "estimatedEmptyReportedAt", "actualEmptyReportedAt", + "emptyReportSourceVersion"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java index 1988f56f3..57841d96e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java @@ -8,6 +8,7 @@ import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributo import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper; import com.baiyue.firstleg.outbound.dispatch.repository.OutboundDispatchRepository; import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; import org.junit.jupiter.api.Test; import java.util.List; @@ -67,6 +68,26 @@ class OutboundDispatchServiceTest { verify(repository).completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1"); } + @Test + void confirmCreatesCanonicalCarrierScheduleTaskInTheSameTransaction() { + CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class); + OutboundDispatchService service = new OutboundDispatchService(repository, + List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")), carrierSchedules); + stubReadyChain(); + OutboundDispatchEntity state = new OutboundDispatchEntity(); + state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); + state.setContainerBookingId(22L); state.setStatus("READY_FOR_DISPATCH"); state.setVersion(0); + when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-carrier"), anyString())).thenReturn(true); + when(repository.confirm(33L, 0, 9L, "admin", null)).thenReturn(1); + when(repository.completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-carrier")).thenReturn(1); + when(repository.timeline(any(), any(), any())).thenReturn(List.of()); + + service.confirm(9L, "admin", "confirm-carrier", new ActionRequest(11L, 22L, 0, null)); + + verify(carrierSchedules).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin"); + } + @Test void confirmNeverWritesStateWhenAnyRealGateFails() { OutboundDispatchService service = new OutboundDispatchService(repository, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java index 774d22da0..d8546b86e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java @@ -9,19 +9,17 @@ import static org.assertj.core.api.Assertions.assertThat; class CarrierScheduleQueryOpenApiContractTest { @Test - void publishesFrequencyFreshnessAndManualFallbackContract() throws Exception { + void publishesBusinessNodesWithoutSchedulerOrAttemptEndpoints() throws Exception { String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); assertThat(yaml) .contains("/api/platform/v1/carrier-schedule-queries:") - .contains("/api/platform/v1/carrier-schedule-queries/{queryId}/attempts:") .contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:") .contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]") + .contains("DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY") .contains("MANUAL_REQUIRED") - .contains("coverageGap") - .contains("lastAttemptAt") - .contains("lastSuccessAt") - .contains("sourceUpdatedAt") - .contains("nextAllowedAt"); + .contains("scheduler leases and raw attempts are never exposed") + .doesNotContain("/{queryId}/attempts:") + .doesNotContain("port-schedule-query:attempt"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java index 1f1d1c0a6..1fb6f87e7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java @@ -22,16 +22,17 @@ class PlatformCarrierScheduleQueryControllerContractTest { "detail", "platform:firstleg:port-schedule-query:list", "history", "platform:firstleg:port-schedule-query:list", "create", "platform:firstleg:port-schedule-query:create", - "attempt", "platform:firstleg:port-schedule-query:attempt", "manualResult", "platform:firstleg:port-schedule-query:manual"); permissions.forEach((name, permission) -> { Method method = Arrays.stream(PlatformCarrierScheduleQueryController.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow(); assertThat(method.getAnnotation(PreAuthorize.class).value()) .isEqualTo("hasAuthority('" + permission + "')"); - if (name.equals("create") || name.equals("attempt") || name.equals("manualResult")) { + if (name.equals("create") || name.equals("manualResult")) { assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull(); } }); + assertThat(Arrays.stream(PlatformCarrierScheduleQueryController.class.getDeclaredMethods()) + .map(Method::getName)).doesNotContain("attempt"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java index 6c6123073..ddaae29ea 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java @@ -17,11 +17,16 @@ class CarrierScheduleQuerySqlContractTest { .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt") .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history") .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation") + .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact") .contains("attempt_count BETWEEN 0 AND 3") .contains("window_attempt_no BETWEEN 1 AND 3") .contains("UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key)") .contains("船司查询尝试不可修改") - .contains("船司查询历史不可删除"); + .contains("船司查询历史不可删除") + .contains("标准船节点事实不可修改") + .contains("ARRIVED','PICKUP_READY','STOPPED") + .contains("lease_owner") + .contains("consecutive_failures"); } @Test @@ -29,9 +34,9 @@ class CarrierScheduleQuerySqlContractTest { assertThat(moduleSql()) .contains("booking.id=NEW.ocean_booking_id AND booking.customer_id=NEW.customer_id") .contains("allocation.container_booking_id=NEW.container_booking_id") - .contains("5102184 AND 5102188") - .contains("platform:firstleg:port-schedule-query:attempt") + .contains("id IN (5102184,5102186,5102188)") .contains("platform:firstleg:port-schedule-query:manual") + .contains("'platform:firstleg:port-schedule-query:attempt'") .contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')") .contains("船司查询任务角色授权未完整创建"); } @@ -43,7 +48,7 @@ class CarrierScheduleQuerySqlContractTest { assertThat(unified) .contains("fl_carrier_schedule_query") .contains("uk_carrier_schedule_query_scope") - .contains("5102184 AND 5102188") + .contains("id IN (5102184,5102186,5102188)") .contains("assert_carrier_schedule_query_schema") .contains("船司查询任务角色授权未完整创建"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java new file mode 100644 index 000000000..c54b003d3 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java @@ -0,0 +1,41 @@ +package com.baiyue.firstleg.portschedule.service; + +import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; +import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class CarrierScheduleQuerySchedulerTest { + @Test + void leaseWinnerPersistsStandardNodesAndCompletesAttempt() { + CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class); + CarrierScheduleQueryService service = mock(CarrierScheduleQueryService.class); + CarrierScheduleIntegrationClient integration = mock(CarrierScheduleIntegrationClient.class); + CarrierScheduleQueryScheduler scheduler = new CarrierScheduleQueryScheduler(repository, service, integration); + CarrierScheduleQueryEntity task = new CarrierScheduleQueryEntity(); task.setId(1L); + task.setCarrierCode("COSCO"); task.setTrackingType("CONTAINER_NO"); task.setTrackingNo("CSNU1234567"); + LocalDateTime now = LocalDateTime.of(2026, 8, 20, 12, 0); + when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(true); + var observation = new CarrierScheduleIntegrationClient.Observation("evt-1", "DISCHARGED", "ARRIVED", + now, null, now, "USLAX", "Los Angeles", "provider:evt-1", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + when(integration.query(any(), any())).thenReturn(new CarrierScheduleIntegrationClient.Envelope<>("200", null, + new CarrierScheduleIntegrationClient.QueryResult("TRACK_API", "SUCCESS", List.of(observation), + null, null, now))); + + scheduler.execute(task, now); + + verify(service).applyProviderObservation(anyLong(), any()); + verify(service).completeScheduledAttempt(1L, "SUCCESS", null, null); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index 8c9801761..2673fe3dc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -3,10 +3,9 @@ package com.baiyue.firstleg.portschedule.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; -import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.AttemptRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; -import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity; import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; @@ -14,7 +13,6 @@ import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -48,7 +46,7 @@ class CarrierScheduleQueryServiceTest { set("clock", Clock.fixed(Instant.parse("2026-08-10T04:00:00Z"), ZoneOffset.UTC)); when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString())).thenReturn(1); - when(repository.attempts(anyLong())).thenReturn(List.of()); + when(repository.nodes(anyLong())).thenReturn(List.of()); when(repository.histories(anyLong())).thenReturn(List.of()); } @@ -62,42 +60,48 @@ class CarrierScheduleQueryServiceTest { var result = service.create(7L, "admin", "carrier-create", new CreateRequest(0, 10001L, 10L, 20L, "ZIM", "BILL_OF_LADING", "GAOU6898053", null)); - assertThat(result.query().query().getCarrierCode()).isEqualTo("ZIM"); - assertThat(result.query().query().getStatus()).isEqualTo("READY"); + assertThat(result.query().query().carrierCode()).isEqualTo("ZIM"); + assertThat(result.query().query().status()).isEqualTo("READY"); assertThat(result.query().expired()).isTrue(); verify(repository).insert(any(CarrierScheduleQueryEntity.class)); } @Test - void recordsManualRequiredAttemptWithoutBypassingCaptcha() { - CarrierScheduleQueryEntity task = task(0, 0); - when(repository.lock(1L)).thenReturn(task); - when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); - when(repository.find(1L)).thenAnswer(invocation -> task); + void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() { + OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); + booking.setShippingCompanyName("中远海运"); booking.setBookingNo("OB-260810-0001"); + var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + container.setContainerNo("CSNU1234567"); + when(portSchedules.oceanBooking(10L)).thenReturn(booking); + when(portSchedules.containerBooking(20L)).thenReturn(container); + when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); - var result = service.attempt(1L, 7L, "admin", "carrier-attempt", - new AttemptRequest(0, "官网查询")); + CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin"); - assertThat(result.query().query().getStatus()).isEqualTo("MANUAL_REQUIRED"); - assertThat(result.query().query().getAttemptCount()).isEqualTo(1); - assertThat(result.query().query().getManualReason()).contains("hCaptcha"); - ArgumentCaptor attempt = - ArgumentCaptor.forClass(CarrierScheduleQueryAttemptEntity.class); - verify(repository).insert(attempt.capture()); - assertThat(attempt.getValue().getCoverageGap()).isTrue(); - assertThat(attempt.getValue().getCoverageGapCode()).isEqualTo("HCAPTCHA_REQUIRED"); + assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM"); + assertThat(result.getCarrierCode()).isEqualTo("COSCO"); + assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO"); + assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE"); + verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)); } @Test - void blocksFourthAttemptInsideSameThreeDayWindow() { - CarrierScheduleQueryEntity task = task(3, 4); + void providerObservationAdvancesToArrivedWithoutExposingAttempts() { + CarrierScheduleQueryEntity task = task(0, 0); + task.setLatestStandardNode("IN_TRANSIT"); when(repository.lock(1L)).thenReturn(task); + when(repository.find(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class))) + .thenReturn(true); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); - assertThatThrownBy(() -> service.attempt(1L, 7L, "admin", "carrier-fourth", - new AttemptRequest(4, null))) - .isInstanceOf(BizException.class).hasMessageContaining("最多查询3次"); + var result = service.applyProviderObservation(1L, new ProviderObservationRequest("TRACK_API", "evt-1", + "DISCHARGED", "ARRIVED", NOW, null, NOW, "USLAX", "Los Angeles", "provider:evt-1", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); - verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString()); + assertThat(result.query().query().status()).isEqualTo("ARRIVED"); + assertThat(result.query().query().latestStandardNode()).isEqualTo("ARRIVED"); + assertThat(result.nodes()).isEmpty(); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index 62fcaae25..5cd6b548c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -149,7 +149,7 @@ class Phase3FinalUnifiedSqlContractTest { .contains("存在缺失中文注释的业务表") .contains("存在缺失中文注释的业务字段") .doesNotContain("CREATE PROCEDURE upgrade_column_comment"); - assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(150); + assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(151); assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2459); assertThat(sql) .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java new file mode 100644 index 000000000..abd8ea172 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java @@ -0,0 +1,96 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.FirstLegLoadingPlanCompletedV1; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.RegionCode; +import com.baiyue.firstleg.client.EventBridgeClient; +import com.baiyue.firstleg.entity.InboundOutboxEntity; +import com.baiyue.firstleg.repository.LoadingCompletedOutboxRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LoadingCompletedOutboxRelayTest { + @Mock private LoadingCompletedOutboxRepository repository; + @Mock private EventBridgeClient client; + private ObjectMapper objectMapper; + private LoadingCompletedOutboxRelay relay; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + relay = new LoadingCompletedOutboxRelay(); + ReflectionTestUtils.setField(relay, "repository", repository); + ReflectionTestUtils.setField(relay, "client", client); + ReflectionTestUtils.setField(relay, "objectMapper", objectMapper); + ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); + ReflectionTestUtils.setField(relay, "leaseSeconds", 60L); + ReflectionTestUtils.setField(relay, "retrySeconds", 30L); + } + + @Test + void claimsExpiredLeasesAndPublishesAfterOverseasBridgeAcknowledgement() throws Exception { + EventEnvelope event = event(); + InboundOutboxEntity row = row(event); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success( + new EventBridgeClient.Acknowledgement(event.eventId(), "已接收", "2026-08-20T03:01:00"))); + + relay.relay(); + + verify(repository).published(eq(9001L), anyString(), any(LocalDateTime.class)); + verify(repository, never()).retry(anyLong(), anyString(), any(), anyString()); + } + + @Test + void redactsInternalTokenBeforePersistingRetryError() throws Exception { + EventEnvelope event = event(); + InboundOutboxEntity row = row(event); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))) + .thenThrow(new IllegalStateException("failed internal-token\nremote")); + + relay.relay(); + + verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq("failed *** remote")); + } + + private InboundOutboxEntity row(EventEnvelope event) throws Exception { + InboundOutboxEntity row = new InboundOutboxEntity(); + row.setId(9001L); + row.setEventKey(event.eventId()); + row.setPayloadJson(objectMapper.writeValueAsString(event)); + return row; + } + + private EventEnvelope event() { + var payload = objectMapper.valueToTree(new FirstLegLoadingPlanCompletedV1( + "LOAD-1", "PLAN-1", "BOOK-1", "MSCU1234567", null, + "WH-1", "Warehouse", List.of())); + return new EventEnvelope("00000000-0000-4000-8000-000000000019", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 1, + Instant.parse("2026-08-20T03:00:01Z"), RegionCode.CN, RegionCode.OVERSEAS, "1", + "LOADING_PLAN", "LOAD-1", 7L, 1L, "trace-12345678", "correlation-12345678", null, + payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java index 390c16439..037490b0e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java @@ -43,12 +43,12 @@ class OverseasContainerEventConsumerTest { @Test void derivesEstimatedEmptyReportedAtFromPhotoUploadAndPersistsInbox() { ContainerBookingEntity container = container(); - when(containers.selectActiveByContainerNo("MSCU1234567")).thenReturn(container); + when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container); Instant completedAt = Instant.parse("2026-08-20T03:00:00Z"); Instant uploadedAt = Instant.parse("2026-08-20T02:30:00Z"); consumer.consume(event("00000000-0000-4000-8000-000000000001", 7, - new OverseasContainerUnloadedV1("MSCU1234567", completedAt, uploadedAt, null))); + new OverseasContainerUnloadedV1("1001", "MSCU1234567", completedAt, uploadedAt, null))); verify(containers).applyEmptyReport(eq(1001L), eq(LocalDateTime.parse("2026-08-20T03:00:00")), eq(LocalDateTime.parse("2026-08-20T02:30:00")), @@ -62,13 +62,13 @@ class OverseasContainerEventConsumerTest { @Test void repeatedEventWithSameHashDoesNotApplyAgain() { - OverseasContainerUnloadedV1 payload = new OverseasContainerUnloadedV1("MSCU1234567", + OverseasContainerUnloadedV1 payload = new OverseasContainerUnloadedV1("1001", "MSCU1234567", Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null); EventEnvelope event = event("00000000-0000-4000-8000-000000000002", 7, payload); ContainerEventInboxEntity existing = new ContainerEventInboxEntity(); existing.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(existing); - when(containers.selectActiveByContainerNo("MSCU1234567")).thenReturn(container()); + when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container()); consumer.consume(event); @@ -79,7 +79,7 @@ class OverseasContainerEventConsumerTest { @Test void rejectsTamperedPayloadHash() { EventEnvelope valid = event("00000000-0000-4000-8000-000000000003", 7, - new OverseasContainerUnloadedV1("MSCU1234567", Instant.parse("2026-08-20T03:00:00Z"), + new OverseasContainerUnloadedV1("1001", "MSCU1234567", Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null)); EventEnvelope tampered = new EventEnvelope(valid.eventId(), valid.eventType(), valid.eventVersion(), valid.occurredAt(), valid.sourceRegion(), valid.targetRegion(), valid.tenantId(), valid.aggregateType(), @@ -96,7 +96,7 @@ class OverseasContainerEventConsumerTest { String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope(eventId, EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, Instant.parse("2026-08-20T02:31:00Z"), RegionCode.OVERSEAS, RegionCode.CN, "1", - "CONTAINER_BOOKING", payload.containerNo(), aggregateVersion, null, + "CONTAINER_BOOKING", payload.sourceContainerBookingId(), aggregateVersion, null, "trace-12345678", "correlation-12345678", null, node, hash); } diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java index 50a208bca..d75af294b 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java @@ -53,7 +53,6 @@ public class GatewaySecurityConfig { .withJwkSetUri(properties.jwkSetUri()) .jwsAlgorithms(algorithms -> { algorithms.add(SignatureAlgorithm.RS256); - algorithms.add(SignatureAlgorithm.ES256); }) .build(); decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>( diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java index d8621a342..4133a793a 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java @@ -5,12 +5,14 @@ import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; import org.springframework.security.oauth2.jwt.Jwt; +import java.util.Collection; import java.util.List; +import java.util.Locale; /** Validates the identity and authorization claims required by overseas APIs. */ final class RequiredClaimsJwtValidator implements OAuth2TokenValidator { private static final List REQUIRED_CLAIMS = List.of( - "sub", "tenant_id", "app_code", "permission_version"); + "sub", "tenant_id", "app_code", "permission_version", "authorities"); @Override public OAuth2TokenValidatorResult validate(Jwt jwt) { @@ -21,6 +23,42 @@ final class RequiredClaimsJwtValidator implements OAuth2TokenValidator { "invalid_token", "JWT 缺少必要声明: " + claim, null)); } } + Long subject = longValue(jwt.getSubject()); + Long tenantId = longValue(jwt.getClaims().get("tenant_id")); + Long permissionVersion = longValue(jwt.getClaims().get("permission_version")); + String appCode = String.valueOf(jwt.getClaims().get("app_code")).toUpperCase(Locale.ROOT); + if (subject == null || subject <= 0) { + return failure("JWT sub 必须是正整数用户 ID"); + } + if (tenantId == null || tenantId < 0) { + return failure("JWT tenant_id 必须是非负整数客户范围"); + } + if (permissionVersion == null || permissionVersion < 0) { + return failure("JWT permission_version 必须是非负整数"); + } + if (!("PLATFORM".equals(appCode) && tenantId == 0L) + && !("MERCHANT".equals(appCode) && tenantId > 0L)) { + return failure("JWT app_code 与 tenant_id 访问域不一致"); + } + Object authorities = jwt.getClaims().get("authorities"); + if (!(authorities instanceof Collection values) + || values.stream().anyMatch(value -> value == null || value.toString().isBlank())) { + return failure("JWT authorities 必须是权限列表"); + } return OAuth2TokenValidatorResult.success(); } + + private static Long longValue(Object value) { + if (value instanceof Number number) return number.longValue(); + if (value == null) return null; + try { + return Long.valueOf(value.toString()); + } catch (NumberFormatException ignored) { + return null; + } + } + + private static OAuth2TokenValidatorResult failure(String message) { + return OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", message, null)); + } } diff --git a/gateway-overseas/src/main/resources/application-overseas.yml b/gateway-overseas/src/main/resources/application-overseas.yml index c1c32029e..5866a7caf 100644 --- a/gateway-overseas/src/main/resources/application-overseas.yml +++ b/gateway-overseas/src/main/resources/application-overseas.yml @@ -59,7 +59,9 @@ springdoc: swagger-ui: urls: - { name: lastleg-merchant, url: /lastleg-service/v3/api-docs/merchant } + - { name: lastleg-platform, url: /lastleg-service/v3/api-docs/platform } - { name: warehouse-merchant, url: /warehouse-overseas-service/v3/api-docs/merchant } + - { name: warehouse-platform, url: /warehouse-overseas-service/v3/api-docs/platform } - { name: file-platform, url: /file-service/v3/api-docs/platform } - { name: file-merchant, url: /file-service/v3/api-docs/merchant } - { name: print-platform, url: /print-service/v3/api-docs/platform } diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java index db83eae5a..8533dc7c7 100644 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; import org.springframework.security.oauth2.jwt.Jwt; import java.time.Instant; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -20,14 +21,58 @@ class RequiredClaimsJwtValidatorTest { assertThat(validator.validate(jwt(false)).hasErrors()).isTrue(); } + @Test + void rejectsMissingAuthorities() { + Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001") + .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) + .claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("permission_version", 3L) + .build(); + assertThat(validator.validate(value).hasErrors()).isTrue(); + } + + @Test + void acceptsEmptyAuthoritySnapshotAndLeavesAuthorizationToControllers() { + assertThat(validator.validate(jwt("0", "PLATFORM", List.of(), true)).hasErrors()).isFalse(); + } + + @Test + void acceptsMerchantIdentityOnlyWithPositiveCustomerScope() { + assertThat(validator.validate(jwt("2001", "MERCHANT", List.of("merchant:order:list"), true)).hasErrors()) + .isFalse(); + } + + @Test + void rejectsApplicationAndCustomerScopeMismatch() { + assertThat(validator.validate(jwt("2001", "PLATFORM", List.of("platform:test:list"), true)).hasErrors()) + .isTrue(); + assertThat(validator.validate(jwt("0", "MERCHANT", List.of("merchant:test:list"), true)).hasErrors()) + .isTrue(); + } + + @Test + void rejectsMalformedIdentityAndPermissionVersion() { + Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("not-a-user") + .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) + .claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("permission_version", -1L) + .claim("authorities", List.of()).build(); + assertThat(validator.validate(value).hasErrors()).isTrue(); + } + private Jwt jwt(boolean includePermissionVersion) { + return jwt("0", "PLATFORM", + List.of("platform:overseas-warehouse:inventory:list"), includePermissionVersion); + } + + private Jwt jwt(String tenantId, String appCode, List authorities, + boolean includePermissionVersion) { Jwt.Builder builder = Jwt.withTokenValue("token") .header("alg", "RS256") .subject("1001") .issuedAt(Instant.now()) .expiresAt(Instant.now().plusSeconds(300)) - .claim("tenant_id", "2001") - .claim("app_code", "PLATFORM"); + .claim("tenant_id", tenantId) + .claim("app_code", appCode) + .claim("authorities", authorities); if (includePermissionVersion) { builder.claim("permission_version", 3L); } diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 98348b610..12b0a7672 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -1,15 +1,67 @@ USE baiyue_iam; --- 尾程订单权限属于平台端;列表权限使用 MENU,其余操作使用 BUTTON。 +-- 四期 Admin 海外权限只使用 platform:overseas-*;列表权限包含详情只读。 +DELETE rp FROM iam_role_permission rp +JOIN iam_permission p ON p.id=rp.permission_id +WHERE p.permission_code LIKE 'platform:lastleg:%'; +UPDATE iam_permission SET status='INACTIVE', deleted=1 +WHERE permission_code LIKE 'platform:lastleg:%'; + INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, deleted) VALUES - (5106000, 0, 'PLATFORM', 'MENU', 0, 'platform:lastleg:order:list', '尾程订单', '/lastleg/orders', 'GET', 60, 'ACTIVE', 1, 0), - (5106001, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:detail', '查看尾程订单详情', NULL, 'GET', 10, 'ACTIVE', 1, 0), - (5106002, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:assign', '分配尾程订单', NULL, 'POST', 20, 'ACTIVE', 1, 0), - (5106003, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:transfer', '转派尾程订单', NULL, 'POST', 30, 'ACTIVE', 1, 0), - (5106004, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:merge-pallet', '合并尾程托盘', NULL, 'POST', 40, 'ACTIVE', 1, 0) + (5107000,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',700,'ACTIVE',1,0), + (5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,0), + (5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,0), + (5107010,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inbound-order:list','海外入仓单','/overseas-warehouse/inbound-orders','GET',710,'ACTIVE',1,0), + (5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,0), + (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0), + (5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,0), + (5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,0), + (5107022,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:status','变更海外换标单状态',NULL,'PUT',722,'ACTIVE',1,0), + (5107030,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:pallet:list','海外托盘','/overseas-warehouse/pallets','GET',730,'ACTIVE',1,0), + (5107031,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:create','创建海外托盘',NULL,'POST',731,'ACTIVE',1,0), + (5107032,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:edit','编辑海外托盘',NULL,'PUT',732,'ACTIVE',1,0), + (5107033,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:delete','删除海外托盘',NULL,'DELETE',733,'ACTIVE',1,0), + (5107040,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inventory:list','海外库存','/overseas-warehouse/inventory','GET',740,'ACTIVE',1,0), + (5107041,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:export','导出海外库存',NULL,'GET',741,'ACTIVE',1,0), + (5107042,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:edit','编辑海外库存状态',NULL,'POST',742,'ACTIVE',1,0), + (5107043,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:adjust','调整海外库存',NULL,'POST',743,'ACTIVE',1,0), + (5107050,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:warehouse:list','海外仓资源','/overseas-warehouse/warehouses','GET',750,'ACTIVE',1,0), + (5107051,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:create','创建海外仓资源',NULL,'POST',751,'ACTIVE',1,0), + (5107052,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:edit','编辑海外仓资源',NULL,'PUT',752,'ACTIVE',1,0), + (5107053,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:status','变更海外仓状态',NULL,'PATCH',753,'ACTIVE',1,0), + (5107060,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:location:list','海外库位','/overseas-warehouse/locations','GET',760,'ACTIVE',1,0), + (5107061,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:create','创建海外库位',NULL,'POST',761,'ACTIVE',1,0), + (5107062,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:edit','编辑海外库位',NULL,'PUT',762,'ACTIVE',1,0), + (5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,0), + (5107100,0,'PLATFORM','MENU',0,'platform:overseas-business:purchase:list','海外采购订单','/overseas-business/purchases','GET',800,'ACTIVE',1,0), + (5107101,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:edit','编辑海外采购订单',NULL,'PATCH',801,'ACTIVE',1,0), + (5107102,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:review','审核海外采购订单',NULL,'POST',802,'ACTIVE',1,0), + (5107110,0,'PLATFORM','MENU',0,'platform:overseas-business:truck:list','本土卡车','/overseas-business/trucks','GET',810,'ACTIVE',1,0), + (5107111,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:create','创建本土卡车单',NULL,'POST',811,'ACTIVE',1,0), + (5107112,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:edit','编辑本土卡车单',NULL,'PUT',812,'ACTIVE',1,0), + (5107113,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:status','变更本土卡车状态',NULL,'PATCH',813,'ACTIVE',1,0), + (5107120,0,'PLATFORM','MENU',0,'platform:overseas-business:looseboard:list','本土散板','/overseas-business/looseboards','GET',820,'ACTIVE',1,0), + (5107121,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:create','创建本土散板单',NULL,'POST',821,'ACTIVE',1,0), + (5107122,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:edit','编辑本土散板单',NULL,'PUT',822,'ACTIVE',1,0), + (5107123,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:status','变更本土散板状态',NULL,'PATCH',823,'ACTIVE',1,0), + (5107200,0,'PLATFORM','MENU',0,'platform:overseas-master-data:quote:list','海外仓报价','/overseas-master-data/quotes','GET',900,'ACTIVE',1,0), + (5107201,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:create','创建海外仓报价',NULL,'POST',901,'ACTIVE',1,0), + (5107202,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:edit','编辑海外仓报价',NULL,'PUT',902,'ACTIVE',1,0), + (5107203,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:status','变更海外仓报价状态',NULL,'PATCH',903,'ACTIVE',1,0), + (5107204,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:export','导出海外仓报价',NULL,'GET',904,'ACTIVE',1,0), + (5107210,0,'PLATFORM','MENU',0,'platform:overseas-master-data:exchange-rate:list','月度汇率','/overseas-master-data/exchange-rates','GET',910,'ACTIVE',1,0), + (5107211,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:create','创建月度汇率',NULL,'POST',911,'ACTIVE',1,0), + (5107212,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:edit','编辑月度汇率',NULL,'PUT',912,'ACTIVE',1,0), + (5107213,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:status','变更月度汇率状态',NULL,'PATCH',913,'ACTIVE',1,0), + (5107214,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:export','导出月度汇率',NULL,'GET',914,'ACTIVE',1,0), + (5107220,0,'PLATFORM','MENU',0,'platform:overseas-master-data:vehicle:list','车辆主数据','/overseas-master-data/vehicles','GET',920,'ACTIVE',1,0), + (5107221,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:create','创建车辆主数据',NULL,'POST',921,'ACTIVE',1,0), + (5107222,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:edit','编辑车辆主数据',NULL,'PUT',922,'ACTIVE',1,0), + (5107223,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:status','变更车辆状态',NULL,'PATCH',923,'ACTIVE',1,0), + (5107224,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:export','导出车辆主数据',NULL,'GET',924,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0; @@ -17,9 +69,6 @@ INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, creat SELECT 0, role.id, permission.id, NOW(3) FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.id BETWEEN 5107000 AND 5107299 WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' - AND role.status='ACTIVE' AND role.deleted=0 - AND permission.permission_code IN ( - 'platform:lastleg:order:list', 'platform:lastleg:order:detail', - 'platform:lastleg:order:assign', 'platform:lastleg:order:transfer', - 'platform:lastleg:order:merge-pallet'); + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java new file mode 100644 index 000000000..6267c9cb1 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -0,0 +1,77 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasPhase4PermissionSqlContractTest { + private static final Pattern CANONICAL = Pattern.compile("platform:overseas-[a-z0-9:-]+"); + private static final List REQUIRED = List.of( + "platform:overseas-warehouse:last-mile-order:list", + "platform:overseas-warehouse:inbound-order:list", + "platform:overseas-warehouse:relabel-order:status", + "platform:overseas-warehouse:pallet:list", + "platform:overseas-warehouse:inventory:adjust", + "platform:overseas-business:purchase:review", + "platform:overseas-business:truck:create", + "platform:overseas-business:looseboard:list", + "platform:overseas-master-data:quote:list", + "platform:overseas-master-data:exchange-rate:list", + "platform:overseas-master-data:vehicle:list"); + + @Test + void canonicalCatalogDisablesLegacyCodesAndExcludesFrozenDomains() throws Exception { + String sql = new ClassPathResource("db/lastleg-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + Set catalog = codes(sql); + assertThat(sql).contains(REQUIRED.toArray(String[]::new)); + assertThat(catalog).hasSize(51).noneMatch(code -> code.endsWith(":view")); + assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1"); + assertThat(sql).doesNotContain(":carton:", ":product:", ":outbound:", ":delivery:"); + } + + @Test + void unifiedSqlContainsTheSameCanonicalPermissionCatalog() throws Exception { + Path path = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(path)) path = Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"); + String sql = Files.readString(path, StandardCharsets.UTF_8); + Set moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8)); + assertThat(codes(sql)).containsAll(moduleCatalog); + assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission"); + } + + @Test + void servicePermissionConstantsExactlyMatchTheIamCatalog() throws Exception { + Set moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8)); + Set serviceCatalog = new LinkedHashSet<>(); + serviceCatalog.addAll(codes(repositoryFile( + "lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java"))); + serviceCatalog.addAll(codes(repositoryFile( + "warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/OverseasWarehousePermissions.java"))); + + assertThat(serviceCatalog).containsExactlyInAnyOrderElementsOf(moduleCatalog); + } + + private static Set codes(String source) { + Set values = new LinkedHashSet<>(); + CANONICAL.matcher(source).results().map(result -> result.group()).forEach(values::add); + return values; + } + + private static String repositoryFile(String relativePath) throws Exception { + Path path = Path.of(relativePath); + if (!Files.exists(path)) path = Path.of("..").resolve(relativePath); + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/integration-service/AGENTS.md b/integration-service/AGENTS.md index 72926fad5..b049ac582 100644 --- a/integration-service/AGENTS.md +++ b/integration-service/AGENTS.md @@ -11,7 +11,7 @@ - 内部接口:`controller/IntegrationProviderController.java`、`WeightOcrController.java` - OCR 编排:`service/ocr/WeightOcrBatchService.java`、`ImageCropper.java`、`WeightCandidateSelector.java` - 供应商适配:`provider/IntegrationProviderAdapter.java`、`BaiduOcrAdapter.java`、`AmazonLogisticsAdapter.java` -- 四期船节点外部适配:继续进入 `provider/` 和内部接口层;轮询任务、订舱/柜次关联、最新节点和节点历史归 `firstleg/portschedule/`,范围见 `../../docs/四期/README.md` +- 四期船节点外部适配:内部契约 `controller/IntegrationProviderController.java` 的 `/internal/integrations/carrier-schedules/query`,统一 DTO `dto/CarrierScheduleDtos.java`,provider SPI `provider/CarrierScheduleProviderAdapter.java`,标准节点 `domain/CarrierScheduleStandardNode.java`;轮询任务、租约、订舱/柜次关联、最新节点和节点历史归 `firstleg/portschedule/`,范围见 `../../docs/四期/README.md` - 安全:`security/InternalServiceTokenGuard.java`;配置 `application.yml`、`../config/nacos/{local,cn}/integration-service.yml` - 测试:`src/test/java/com/baiyue/integration/`;`mvn -pl integration-service -am test` diff --git a/integration-service/src/main/java/com/baiyue/integration/controller/IntegrationProviderController.java b/integration-service/src/main/java/com/baiyue/integration/controller/IntegrationProviderController.java index 6205eac7b..a2a279d03 100644 --- a/integration-service/src/main/java/com/baiyue/integration/controller/IntegrationProviderController.java +++ b/integration-service/src/main/java/com/baiyue/integration/controller/IntegrationProviderController.java @@ -4,10 +4,16 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.integration.dto.ProviderStatus; import com.baiyue.integration.security.InternalServiceTokenGuard; import com.baiyue.integration.service.ProviderStatusService; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.service.CarrierScheduleProviderService; +import jakarta.validation.Valid; import jakarta.annotation.Resource; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import java.util.List; @@ -19,6 +25,8 @@ public class IntegrationProviderController { private ProviderStatusService providerStatusService; @Resource private InternalServiceTokenGuard internalServiceTokenGuard; + @Resource + private CarrierScheduleProviderService carrierScheduleProviderService; @GetMapping("/providers") public ApiResponse> providers( @@ -26,4 +34,12 @@ public class IntegrationProviderController { internalServiceTokenGuard.verify(token); return ApiResponse.success(providerStatusService.providers()); } + + @PostMapping("/carrier-schedules/query") + public ApiResponse queryCarrierSchedule( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody QueryRequest request) { + internalServiceTokenGuard.verify(token); + return ApiResponse.success(carrierScheduleProviderService.query(request)); + } } diff --git a/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java b/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java new file mode 100644 index 000000000..430ec27fc --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java @@ -0,0 +1,17 @@ +package com.baiyue.integration.domain; + +import java.util.Locale; + +public enum CarrierScheduleStandardNode { + DEPARTED_WAREHOUSE, + AT_ORIGIN_PORT, + DEPARTED_ORIGIN_PORT, + IN_TRANSIT, + ARRIVED, + PICKUP_READY; + + public static boolean supports(String value) { + try { valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); return true; } + catch (IllegalArgumentException exception) { return false; } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/CarrierScheduleDtos.java b/integration-service/src/main/java/com/baiyue/integration/dto/CarrierScheduleDtos.java new file mode 100644 index 000000000..4d94ac5ae --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/CarrierScheduleDtos.java @@ -0,0 +1,23 @@ +package com.baiyue.integration.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class CarrierScheduleDtos { + private CarrierScheduleDtos() { } + + public record QueryRequest(@NotBlank @Size(max = 16) String carrierCode, + @NotBlank @Size(max = 32) String trackingType, + @NotBlank @Size(max = 128) String trackingNo) { } + + public record QueryResult(String providerCode, String outcome, List observations, + String errorCode, String errorMessage, LocalDateTime providerObservedAt) { } + + public record Observation(String providerEventId, String providerNodeCode, String standardNode, + LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt, + String locationCode, String locationName, String sourceReference, + String rawPayloadHash) { } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/CarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/CarrierScheduleProviderAdapter.java new file mode 100644 index 000000000..7987204e0 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/CarrierScheduleProviderAdapter.java @@ -0,0 +1,9 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; + +public interface CarrierScheduleProviderAdapter { + boolean supports(String carrierCode); + QueryResult query(QueryRequest request); +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/CompliantCarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/CompliantCarrierScheduleProviderAdapter.java new file mode 100644 index 000000000..9a4f219a2 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/CompliantCarrierScheduleProviderAdapter.java @@ -0,0 +1,35 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.dto.ProviderStatus; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Safe fallback for the six confirmed carriers. It never scrapes captcha/dynamic pages and returns + * an explicit provider-unavailable outcome until a documented provider API adapter is configured. + */ +@Component +public class CompliantCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter { + private static final Set CARRIERS = Set.of("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE"); + + @Override + public boolean supports(String carrierCode) { + return carrierCode != null && CARRIERS.contains(carrierCode.trim().toUpperCase(Locale.ROOT)); + } + + @Override + public QueryResult query(QueryRequest request) { + return new QueryResult("COMPLIANT_MANUAL_FALLBACK", "MANUAL_REQUIRED", List.of(), + "PROVIDER_API_NOT_CONFIGURED", "未配置可审计的结构化轨迹供应商接口,不执行网页抓取", + LocalDateTime.now()); + } + + @Override + public ProviderStatus status() { return ProviderStatus.of("CARRIER_SCHEDULE", true, false); } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java b/integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java new file mode 100644 index 000000000..f02110ab0 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java @@ -0,0 +1,39 @@ +package com.baiyue.integration.service; + +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.provider.CarrierScheduleProviderAdapter; +import com.baiyue.integration.domain.CarrierScheduleStandardNode; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; + +@Service +public class CarrierScheduleProviderService { + private static final Set OUTCOMES = Set.of("SUCCESS", "NO_CHANGE", "RETRYABLE_FAILURE", + "PERMANENT_FAILURE", "MANUAL_REQUIRED"); + private final List adapters; + + public CarrierScheduleProviderService(List adapters) { + this.adapters = List.copyOf(adapters); + } + + public QueryResult query(QueryRequest request) { + QueryResult result = adapters.stream().filter(adapter -> adapter.supports(request.carrierCode())).findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "没有支持该船司的轨迹供应商适配器:" + request.carrierCode())) + .query(request); + if (result == null || result.observations() == null || !OUTCOMES.contains(result.outcome())) { + throw new IllegalStateException("轨迹供应商返回空结果"); + } + if (result.observations().stream().anyMatch(value -> value == null + || !CarrierScheduleStandardNode.supports(value.standardNode()) + || value.providerEventId() == null || value.providerEventId().isBlank() + || value.observedAt() == null || value.rawPayloadHash() == null + || !value.rawPayloadHash().matches("[0-9a-f]{64}"))) { + throw new IllegalStateException("轨迹供应商返回了无效标准节点"); + } + return result; + } +} diff --git a/integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml b/integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml new file mode 100644 index 000000000..54a27df9d --- /dev/null +++ b/integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml @@ -0,0 +1,55 @@ +openapi: 3.0.3 +info: + title: Carrier Schedule Provider Internal API + version: 1.0.0 + description: Internal provider adapter contract. It owns normalization, timeout, rate-limit and provider errors, not business tasks. +paths: + /internal/integrations/carrier-schedules/query: + post: + summary: Query one carrier tracking identifier through the configured provider adapter + parameters: + - name: X-Internal-Service-Token + in: header + required: true + schema: {type: string} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/QueryRequest'} + responses: + '200': {description: Standard observations or an explicit normalized failure/manual outcome} +components: + schemas: + QueryRequest: + type: object + required: [carrierCode, trackingType, trackingNo] + properties: + carrierCode: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]} + trackingType: {type: string, enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]} + trackingNo: {type: string, minLength: 1, maxLength: 128} + StandardNode: + type: string + enum: [DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY] + QueryResult: + type: object + required: [providerCode, outcome, observations] + properties: + providerCode: {type: string} + outcome: {type: string, enum: [SUCCESS, NO_CHANGE, RETRYABLE_FAILURE, PERMANENT_FAILURE, MANUAL_REQUIRED]} + observations: + type: array + items: + type: object + required: [providerEventId, providerNodeCode, standardNode, observedAt, rawPayloadHash] + properties: + providerEventId: {type: string} + providerNodeCode: {type: string} + standardNode: {$ref: '#/components/schemas/StandardNode'} + observedAt: {type: string, format: date-time} + estimatedAt: {type: string, format: date-time, nullable: true} + actualAt: {type: string, format: date-time, nullable: true} + locationCode: {type: string, nullable: true} + locationName: {type: string, nullable: true} + sourceReference: {type: string, nullable: true} + rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'} diff --git a/integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java b/integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java new file mode 100644 index 000000000..e5ec3bc04 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java @@ -0,0 +1,41 @@ +package com.baiyue.integration.service; + +import com.baiyue.integration.dto.CarrierScheduleDtos.Observation; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.provider.CarrierScheduleProviderAdapter; +import com.baiyue.integration.provider.CompliantCarrierScheduleProviderAdapter; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CarrierScheduleProviderServiceTest { + @Test + void fallbackNeverPretendsThatWebsiteScrapingSucceeded() { + var service = new CarrierScheduleProviderService(List.of(new CompliantCarrierScheduleProviderAdapter())); + QueryResult result = service.query(new QueryRequest("COSCO", "CONTAINER_NO", "CSNU1234567")); + assertThat(result.outcome()).isEqualTo("MANUAL_REQUIRED"); + assertThat(result.errorCode()).isEqualTo("PROVIDER_API_NOT_CONFIGURED"); + assertThat(result.observations()).isEmpty(); + } + + @Test + void rejectsProviderSpecificNodesThatWereNotNormalized() { + CarrierScheduleProviderAdapter invalid = new CarrierScheduleProviderAdapter() { + @Override public boolean supports(String carrierCode) { return true; } + @Override public QueryResult query(QueryRequest request) { + return new QueryResult("TEST", "SUCCESS", List.of(new Observation("evt", "RAW", "UNKNOWN", + LocalDateTime.now(), null, null, null, null, null, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), null, null, + LocalDateTime.now()); + } + }; + var service = new CarrierScheduleProviderService(List.of(invalid)); + assertThatThrownBy(() -> service.query(new QueryRequest("COSCO", "CONTAINER_NO", "CSNU1234567"))) + .hasMessageContaining("无效标准节点"); + } +} diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 1d5999c8b..08dc82844 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -3,7 +3,7 @@ ## 职责与边界 - 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立尾程订单、明细冻结快照和初始状态历史。 -- 负责尾程订单列表、详情、导出和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业。 +- 负责尾程订单列表、详情、导出和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业;PDA 卸柜完成后通过本地 Outbox 向国内头程回传货柜报空事实。 - 不查询头程数据库,不调用头程接口补齐建单字段;事件快照缺失时应拒绝消费并保留可诊断错误。 ## 快速索引 @@ -17,16 +17,19 @@ | 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` | | 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper | | 内部令牌保护 | `security/InternalServiceTokenGuard.java` | -| 入仓计划转换/查询 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/service/InboundPlanService.java` | +| 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanSummary.java`、`inboundplan/service/InboundPlanService.java` | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | -| PDA 入仓、实收、上架、合托、完成 | `inboundplan/pda/controller/`、`inboundplan/pda/service/InboundPdaWorkflowService.java` | -| 入仓后库存余额、库位和流水 | `inventory/`;平台库存命令从 `inventory/command/controller/InventoryCommandController.java` 进入 | +| PDA 入仓、实收、上架、合托、完成 | `inboundplan/pda/controller/`、`inboundplan/pda/service/InboundPdaWorkflowService.java`;入口固定为 `/api/pda/v1/last-leg/**`,不得挂入平台浏览器 API | +| 卸柜照片与海外卸柜完成 Outbox | `inboundplan/pda/controller/InboundPdaController.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | +| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | | 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` | | 一件代发接收、SKU/箱唛库存 | `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` | | 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` | -| 海外采购、客户下单、采购审核、手工出库与采购库存 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java` | +| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | | 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO | +| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | +| 海外仓报价、月度汇率、车辆主数据 | `masterdata/quote/`、`masterdata/exchange/`、`masterdata/vehicle/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`;车辆档案不含派送履约历史 | | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` | | 测试 | `src/test/java/com/baiyue/lastleg/` | @@ -34,19 +37,21 @@ - 四期在本服务内只进入尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购和本土卡车;本土散板新增实现也归本服务的独立稳定子域。 - `dispatch/`、`delivery/`、出库单、PDA 出库拣货、派送和其他库存离仓能力虽然已存在于服务代码或表结构中,但不属于当前四期开发与验收范围,不得因代码已存在而自动纳入。 +- 历史 `dispatch/`、`delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。 - 海外仓库、库位、实物托盘和资源锁归 `warehouse-overseas-service/`;浏览器 API 统一经 `gateway-overseas/`,本服务不得复制仓储资源主数据或另建公网认证入口。 ## 数据库表索引与注释约束 | 业务域 | 表名 | 中文含义 | |---|---|---| -| 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件、尾程订单、明细及状态历史 | +| 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件/发件、尾程订单、明细及状态历史 | | 入仓计划与作业 | `ll_inbound_plan*`、`ll_inbound_pda*`、`ll_inbound_pallet_merge`、`ll_inbound_fba_appointment*` | 入仓计划、托盘分配、PDA 作业、合托及 FBA 预约 | | 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt` | 库存余额、仓位、流水、命令幂等及实收快照 | | 派车与出库 | `ll_dispatch_*`、`ll_outbound_*`、`ll_delivery_order` | 派车、PDA 拣货、出库及派送 | | 转仓与换标 | `ll_warehouse_transfer_*`、`ll_relabel_*` | 转仓单、库存转移、目标收货及换标 | | 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 | -| 采购与卡车 | `ll_overseas_procurement_*`、`ll_us_truck_*` | 海外采购及美国卡车运输 | +| 采购、卡车与本土散板 | `ll_overseas_procurement_*`、`ll_us_truck_*`、`ll_local_looseboard_*` | 海外采购、美国卡车运输及本土散板订单 | +| 通用资料 | `ll_overseas_quote*`、`ll_monthly_exchange_rate*`、`ll_vehicle` | 海外仓报价、月度汇率及车辆档案;不保存车辆派送履约历史 | - 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义,不得只写英文、字段名或类型名。 - `SchemaChineseCommentContractTest` 自动扫描全部 `ll_` 表,无需维护表名白名单;新增字段必须同步更新基线 SQL、建表增量和存量备注迁移,增量脚本的 `ADD/MODIFY COLUMN` 不得丢失中文备注。 @@ -61,14 +66,16 @@ - 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。 - 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 - 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 +- PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 ## 查询与租户约束 - 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。 +- 常规入仓库存余额以客户、海外仓、目的仓、入仓单和 SKU 为唯一业务维度;PDA 完成入库时按冻结商品明细精确拆分 SKU 箱数,转仓收货继承来源库存 SKU,不得回退为仅按入仓单汇总。 - 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 - 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 - 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。 -- 海外采购客户下单固定进入 `PENDING_REVIEW`;采购表的 `inbound_quantity - outbound_quantity` 只表示采购业务单据余量,不得写入或冒充 `available_packages`。审核/下单只能停留在采购单据(或明确的 `procurement_packages` 桶);只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。 +- 海外采购客户下单固定进入 `PENDING_REVIEW`;四期采购只维护采购订单、审核、入库数量、候选快照和操作流水,不提供采购出库端点、出库记录或出库数量。采购单据不得写入或冒充 `available_packages`;只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。 - 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。 - `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。 diff --git a/lastleg-service/pom.xml b/lastleg-service/pom.xml index 4f86b09f9..938233013 100644 --- a/lastleg-service/pom.xml +++ b/lastleg-service/pom.xml @@ -13,6 +13,8 @@ com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config com.alibaba.cloudspring-cloud-starter-alibaba-sentinel + org.springframework.cloudspring-cloud-starter-openfeign + org.springframework.cloudspring-cloud-starter-loadbalancer com.alibaba.cloudspring-cloud-starter-stream-rocketmq org.springframework.bootspring-boot-starter-actuator com.mysqlmysql-connector-jruntime @@ -20,4 +22,3 @@ org.springframework.bootspring-boot-maven-plugin - diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java b/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java index 3b63ff22f..8fb28f552 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java @@ -3,11 +3,15 @@ package com.baiyue.lastleg; 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; /** * 尾程微服务预留模块启动入口,初始化 Spring Boot 应用并扫描尾程 Mapper。 */ @MapperScan("com.baiyue.lastleg.mapper") +@EnableFeignClients(basePackages = "com.baiyue.lastleg") +@EnableScheduling @SpringBootApplication(scanBasePackages = "com.baiyue") public class BaiyueLastlegApplication { public static void main(String[] args) { SpringApplication.run(BaiyueLastlegApplication.class, args); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java new file mode 100644 index 000000000..e6ab0d6fa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +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; + +/** 将海外业务 Outbox 投递到国内区域事件桥。 */ +@FeignClient(name = "event-bridge-service", contextId = "overseasContainerEventBridgeClient", + url = "${baiyue.firstleg.event-bridge-url:}") +public interface EventBridgeClient { + @PostMapping("/internal/event-bridge/events") + ApiResponse receive( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody EventEnvelope envelope); + + record Acknowledgement(String eventId, String result, String receivedAt) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index d3373c2db..79997bf4a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -8,6 +8,7 @@ import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import com.baiyue.lastleg.security.OverseasPermissions; import com.baiyue.lastleg.service.LastlegOrderService; import com.baiyue.lastleg.service.LoadingCompletedConsumer; import io.swagger.v3.oas.annotations.Operation; @@ -19,6 +20,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -48,7 +50,8 @@ public class LastlegOrderController { } @GetMapping("/api/platform/v1/last-leg/orders") - @Operation(summary = "Page tail-leg order rows") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + @Operation(summary = "Page tail-leg order rows", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST) public ApiResponse> list( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -57,7 +60,8 @@ public class LastlegOrderController { } @GetMapping("/api/platform/v1/last-leg/orders/{id}") - @Operation(summary = "Get one inbound-code-level tail-leg order") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + @Operation(summary = "Get one inbound-code-level tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST) public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -66,7 +70,8 @@ public class LastlegOrderController { } @GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv") - @Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EXPORT + "')") + @Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EXPORT) public ResponseEntity export( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -79,7 +84,8 @@ public class LastlegOrderController { } @PutMapping("/api/platform/v1/last-leg/orders/{id}/destination") - @Operation(summary = "Transfer one order to a destination warehouse") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "Transfer one order to a destination warehouse", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) public ApiResponse transferOne( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -89,7 +95,8 @@ public class LastlegOrderController { } @PutMapping("/api/platform/v1/last-leg/orders/destination") - @Operation(summary = "Transfer multiple orders to a destination warehouse") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "Transfer multiple orders to a destination warehouse", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) public ApiResponse transferBatch( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java index 49088ef0a..b56b7d5e3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -10,12 +10,14 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @RestController +@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true") @RequestMapping("/api/platform/v1/last-leg") @Tag(name = "Outbound and delivery") public class DeliveryTransferController { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index 643058a20..b763efe88 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -1,6 +1,5 @@ package com.baiyue.lastleg.delivery.dto; -import com.baiyue.common.core.api.PageResult; import java.time.LocalDateTime; public final class DeliveryResponses { @@ -17,11 +16,5 @@ public final class DeliveryResponses { String overseasWarehouseId, String status, int palletCount, int packageCount, LocalDateTime createdAt) {} - public record PalletRow(Long id, Long warehousePalletId, String palletNo, String inboundCode, - String orderRefs, String destinationWarehouseId, - String destinationWarehouseCode, String destinationWarehouseName, - int packageCount) {} - - public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery, - PageResult pallets) {} + public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index b7e335447..57ec1814b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -136,14 +136,7 @@ public class DeliveryTransferService { OutboundOrder outbound = outboundOrders.findByNo(customerId, outboundNo); if (outbound == null) throw notFound("Outbound order not found"); DeliveryOrder delivery = requireDelivery(customerId, outbound.getId()); - Page page = outboundPallets.selectPage(new Page<>(query.page(), query.size()), - new LambdaQueryWrapper().eq(OutboundPalletItem::getCustomerId, customerId) - .eq(OutboundPalletItem::getOutboundOrderId, outbound.getId()) - .eq(StringUtils.hasText(query.destinationWarehouseCode()), - OutboundPalletItem::getDestinationWarehouseCode, query.destinationWarehouseCode()) - .orderByAsc(OutboundPalletItem::getId)); - return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery), - page(page, page.getRecords().stream().map(this::palletRow).toList())); + return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery)); } public PageResult deliveryPage(String customerId, DeliveryRequests.Query query) { @@ -206,7 +199,6 @@ public class DeliveryTransferService { } private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt()); } private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getStatus(), d.getPalletCount(), d.getPackageCount(), d.getCreatedAt()); } - private DeliveryResponses.PalletRow palletRow(OutboundPalletItem p) { return new DeliveryResponses.PalletRow(p.getId(), p.getWarehousePalletId(), p.getPalletNo(), p.getInboundCode(), p.getOrderRefs(), p.getDestinationWarehouseId(), p.getDestinationWarehouseCode(), p.getDestinationWarehouseName(), p.getPackageCount()); } private static PageResult page(Page page, List records) { return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java index 9cf6b7f44..15b600c54 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java @@ -15,6 +15,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -31,6 +32,7 @@ import org.springframework.web.server.ResponseStatusException; import java.util.List; @RestController +@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true") @RequestMapping("/api/platform/v1/last-leg/dispatches") @Tag(name = "Tail-leg vehicle dispatch") public class DispatchController { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java index e45211b0b..77005a1e8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java @@ -7,13 +7,15 @@ import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; import jakarta.annotation.Resource; import jakarta.validation.Valid; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @RestController -@RequestMapping("/api/platform/v1/last-leg/dispatches/{dispatchNo}/pda") +@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true") +@RequestMapping("/api/pda/v1/last-leg/dispatches/{dispatchNo}") public class DispatchPdaController { public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; @Resource private DispatchPdaWorkflowService service; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java index c73e5fb3b..6c1285038 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java @@ -1,27 +1,15 @@ package com.baiyue.lastleg.dto; -import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderDetail; -import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; -import com.baiyue.lastleg.dropship.entity.DropshipCarton; -import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; + +import java.time.LocalDateTime; import java.util.List; public record LastlegOrderDetailResponse( - LastlegOrder order, + LastlegOrderSummary order, List cargo, - List statusHistory, - List dropshipPallets + List statusHistory ) { - public record DropshipPallet( - Long profileId, - String sourcePalletNo, - Long warehousePalletId, - String warehousePalletNo, - Long inventoryPositionId, - String status, - List skuInventory, - List cartons - ) {} + public record History(Long id, String fromStatus, String toStatus, String remark, LocalDateTime createdAt) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index 0ae6357f7..8cc4eeaaf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -26,9 +26,6 @@ public record LastlegOrderSummary( Boolean detained, Integer palletCount, Integer packageCount, - Integer pickedPackageCount, - Integer shippedPackageCount, - Integer remainingPackageCount, BigDecimal volumeCbm, Integer productQuantity, BigDecimal volumeWeightKg, @@ -36,8 +33,6 @@ public record LastlegOrderSummary( LocalDateTime actualArrivalAt, LocalDateTime estimatedWarehouseAt, String inboundOrderNo, - String outboundOrderNo, - String deliveryOrderNo, String relabelOrderNo, String relabelStatus, String palletMergeFlag, @@ -46,6 +41,7 @@ public record LastlegOrderSummary( String privateRemark, String customerRemark, String warehouseRemark, - LocalDateTime createdAt + LocalDateTime createdAt, + LocalDateTime updatedAt ) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java index a1179f95d..032425af3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java @@ -23,6 +23,8 @@ public class LastlegEventOutbox { private Integer retryCount; private LocalDateTime nextRetryAt; private LocalDateTime publishedAt; + private LocalDateTime lockedAt; + private String lockedBy; private String lastError; private LocalDateTime createdAt; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 3e9cb5d66..20ebe6d42 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -1,20 +1,25 @@ package com.baiyue.lastleg.inboundplan.controller; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanSummary; import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest; import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; +import com.baiyue.lastleg.security.OverseasPermissions; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -25,15 +30,27 @@ public class InboundPlanController { @Resource private InboundPlanService service; @Resource private InboundFbaAppointmentService appointmentService; + @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") + @Operation(summary = "Page inbound plans without PDA, internal-event or outbound workflow fields", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid InboundPlanRequests.Query query) { + return ApiResponse.success(service.page(scope(user, customerId), query)); + } + @PostMapping("/convert") - @Operation(summary = "Convert one inbound order to one inbound plan") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") + @Operation(summary = "Convert one inbound order to one inbound plan", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ConvertInboundPlanRequest request) { return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId())); } @GetMapping("/{planNo}") - @Operation(summary = "Get an inbound plan with lines, pallets and allocations") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") + @Operation(summary = "Get an inbound plan with lines, pallets and allocations", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, @RequestParam(required = false) String customerId) { @@ -41,7 +58,8 @@ public class InboundPlanController { } @PostMapping("/{planNo}/pallets/supplement") - @Operation(summary = "Add one printable supplemental pallet for a plan destination warehouse") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") + @Operation(summary = "Add one printable supplemental pallet for a plan destination warehouse", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT) public ApiResponse supplementPallet(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, @Valid @RequestBody SupplementPalletRequest request) { @@ -49,7 +67,8 @@ public class InboundPlanController { } @PostMapping("/fba-appointments/batch") - @Operation(summary = "Create one FBA appointment for selected inbound lines") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") + @Operation(summary = "Create one FBA appointment for selected inbound lines", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT) public ApiResponse batchFbaAppointment( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody BatchFbaAppointmentRequest request) { @@ -59,7 +78,8 @@ public class InboundPlanController { } @GetMapping("/fba-appointments/{appointmentNo}") - @Operation(summary = "Get an FBA appointment and its selected inbound lines") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") + @Operation(summary = "Get an FBA appointment and its selected inbound lines", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) public ApiResponse fbaAppointmentDetail( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String appointmentNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java new file mode 100644 index 000000000..b7a4a5d11 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import java.time.LocalDateTime; + +public final class InboundPlanRequests { + private InboundPlanRequests() { + } + + public record Query( + @Min(1) long page, + @Min(1) @Max(200) long size, + String planNo, + String inboundCode, + String status, + String overseasWarehouseId, + String destinationWarehouseCode, + String fbaWarehouseCode, + String appointmentStatus, + LocalDateTime createdFrom, + LocalDateTime createdTo + ) { + public Query { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanSummary.java new file mode 100644 index 000000000..e386e184c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanSummary.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Schema(description = "Inbound plan row for the platform browser API; outbound workflow fields are intentionally excluded") +public record InboundPlanSummary( + Long id, + String planNo, + Long sourceOrderId, + String customerId, + String customerName, + String orderType, + String inboundCode, + String containerNo, + String overseasWarehouseId, + String overseasWarehouseName, + String destinationWarehouseId, + String destinationWarehouseCode, + String destinationWarehouseName, + String fbaWarehouseCode, + Integer packageCount, + BigDecimal totalVolumeCbm, + Integer palletCount, + String status, + String appointmentStatus, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java index c8c7f0c6a..68bc44b2c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java @@ -1,13 +1,50 @@ package com.baiyue.lastleg.inboundplan.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanSummary; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import java.util.List; + @Mapper public interface InboundPlanMapper extends BaseMapper { + String PAGE_FROM = " FROM ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id AND o.customer_id=p.customer_id " + + "LEFT JOIN ll_inbound_plan_line l ON l.plan_id=p.id AND l.order_id=p.source_order_id " + + "WHERE p.customer_id=#{customerId}" + + " AND p.plan_no LIKE CONCAT('%',#{q.planNo},'%')" + + " AND l.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + + " AND p.status=#{q.status}" + + " AND o.overseas_warehouse_id=#{q.overseasWarehouseId}" + + " AND l.destination_warehouse_code=#{q.destinationWarehouseCode}" + + " AND l.fba_warehouse_code=#{q.fbaWarehouseCode}" + + " " + + "AND EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id " + + "WHERE ai.plan_id=p.id AND a.customer_id=#{customerId} AND a.appointment_status=#{q.appointmentStatus})" + + " AND NOT EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai " + + "WHERE ai.plan_id=p.id)" + + " AND p.created_at >=#{q.createdFrom}" + + " AND p.created_at <=#{q.createdTo}"; + + @Select("") + List pageRows(@Param("customerId") String customerId, + @Param("q") InboundPlanRequests.Query query, + @Param("offset") long offset, + @Param("limit") long limit); + + @Select("") + long countRows(@Param("customerId") String customerId, @Param("q") InboundPlanRequests.Query query); + @Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND source_order_id=#{sourceOrderId} LIMIT 1") InboundPlan findBySourceOrder(@Param("customerId") String customerId, @Param("sourceOrderId") Long sourceOrderId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java index f024f8efd..eb33d0f29 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java @@ -17,7 +17,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @RestController -@RequestMapping("/api/platform/v1/last-leg/inbound-plans/{planNo}/pallets") +@RequestMapping("/api/pda/v1/last-leg/inbound-plans/{planNo}/pallets") public class InboundPalletAssignmentController { @Resource private InboundPdaWorkflowService service; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java index 9cc116ea1..0c11e1f3b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java @@ -21,7 +21,7 @@ import org.springframework.web.server.ResponseStatusException; import java.util.List; @RestController -@RequestMapping("/api/platform/v1/last-leg/inbound-plans/{planNo}/pda") +@RequestMapping("/api/pda/v1/last-leg/inbound-plans/{planNo}") public class InboundPdaController { public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; @Resource private InboundPdaWorkflowService service; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java index 814967824..410985cdc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java @@ -20,6 +20,9 @@ public class InboundStockReceipt { private Long lineId; private Long orderId; private String inboundCode; + private String skuCode; + private String productNameZh; + private String productNameEn; private Long warehousePalletId; private String palletNo; private Long warehouseLocationId; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java index a2ec49f0f..69fa42e4b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.pda.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; @@ -13,6 +14,7 @@ import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.dropship.service.DropshipOrderService; @@ -34,6 +36,7 @@ import java.util.stream.Collectors; @Service public class InboundStockReceiptService { @Resource private LastlegOrderMapper orders; + @Resource private LastlegOrderDetailMapper orderDetails; @Resource private InboundStockReceiptMapper receipts; @Resource private InventoryCommandService inventory; @Resource private DropshipOrderService dropshipOrders; @@ -79,9 +82,22 @@ public class InboundStockReceiptService { if (pickedPackages != value(line.getRequiredPackageCount())) { throw conflict("PDA picked packages must equal the inbound order package total"); } + List skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType()) + ? List.of() : skuRemainders(order.getId(), pickedPackages); for (InboundPlanPalletAllocation allocation : lineAllocations) { - created.add(persist(customerId, plan, task, line, allocation, - requirePallet(palletById, allocation), order, receivedAt)); + InboundPlanPallet pallet = requirePallet(palletById, allocation); + if ("ONE_CLICK_DROPSHIP".equals(order.getOrderType())) { + created.add(persist(customerId, plan, task, line, allocation, pallet, order, + SkuSnapshot.unspecified(value(allocation.getPickedPackageCount())), receivedAt)); + } else { + long allocationPackages = value(allocation.getPickedPackageCount()); + for (SkuSnapshot sku : consume(skuRemainders, allocationPackages)) { + created.add(persist(customerId, plan, task, line, allocation, pallet, order, sku, receivedAt)); + } + } + } + if (skuRemainders.stream().anyMatch(value -> value.remaining > 0)) { + throw conflict("Inbound SKU package snapshots were not fully received"); } } activateDropship(customerId, task.getId(), orderById, created); @@ -112,20 +128,21 @@ public class InboundStockReceiptService { private InboundStockReceipt persist(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line, InboundPlanPalletAllocation allocation, InboundPlanPallet pallet, - LastlegOrder order, LocalDateTime receivedAt) { - long packages = value(allocation.getPickedPackageCount()); + LastlegOrder order, SkuSnapshot sku, LocalDateTime receivedAt) { + long packages = sku.packages(); if (packages <= 0 || pallet.getWarehousePalletId() == null) { throw conflict("Stock-in requires picked packages and an assigned warehouse pallet"); } String destinationCode = required(first(line.getDestinationWarehouseCode(), order.getFbaWarehouseCode()), "Destination warehouse code is required for stock-in"); - String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId(); + String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId() + ":" + sku.skuCode(); String overseasWarehouseId = required(order.getOverseasWarehouseId(), "Actual overseas warehouse is required for stock-in"); InventoryCommandResponse result = inventory.stockIn(customerId, task.getOperatorId(), new InventoryCommandRequests.StockIn(null, order.getCustomerName(), overseasWarehouseId, null, order.getOverseasWarehouseName(), line.getDestinationWarehouseId(), destinationCode, line.getDestinationWarehouseName(), order.getId(), plan.getPlanNo(), line.getInboundCode(), + sku.skuCode(), sku.productNameZh(), sku.productNameEn(), pallet.getWarehousePalletId(), pallet.getPalletNo(), plan.getPlanNo(), pallet.getWarehouseLocationId(), null, packages, order.getOrderNo(), order.getSourceEventId(), operationKey, @@ -140,6 +157,9 @@ public class InboundStockReceiptService { receipt.setLineId(line.getId()); receipt.setOrderId(order.getId()); receipt.setInboundCode(line.getInboundCode()); + receipt.setSkuCode(sku.skuCode()); + receipt.setProductNameZh(sku.productNameZh()); + receipt.setProductNameEn(sku.productNameEn()); receipt.setWarehousePalletId(pallet.getWarehousePalletId()); receipt.setPalletNo(pallet.getPalletNo()); receipt.setWarehouseLocationId(pallet.getWarehouseLocationId()); @@ -154,6 +174,46 @@ public class InboundStockReceiptService { return receipt; } + private List skuRemainders(Long orderId, long expectedPackages) { + List details = orderDetails.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(LastlegOrderDetail::getOrderId, orderId).orderByAsc(LastlegOrderDetail::getId)); + Map grouped = new java.util.LinkedHashMap<>(); + for (LastlegOrderDetail detail : details) { + String skuCode = trim(detail.getSkuCode()); + long packages = value(detail.getPackageCount()); + if (skuCode == null || packages <= 0) continue; + SkuRemainder existing = grouped.get(skuCode); + if (existing == null) { + grouped.put(skuCode, new SkuRemainder(skuCode, trim(detail.getProductNameZh()), + trim(detail.getProductNameEn()), packages)); + } else { + existing.remaining += packages; + } + } + long actualPackages = grouped.values().stream().mapToLong(value -> value.remaining).sum(); + if (grouped.isEmpty() || actualPackages != expectedPackages) { + throw conflict("Inbound SKU package snapshots must exactly match the received package total"); + } + return new java.util.ArrayList<>(grouped.values()); + } + + private static List consume(List remainders, long packages) { + List result = new java.util.ArrayList<>(); + long pending = packages; + for (SkuRemainder remainder : remainders) { + if (pending == 0) break; + if (remainder.remaining == 0) continue; + long accepted = Math.min(pending, remainder.remaining); + result.add(new SkuSnapshot(remainder.skuCode, remainder.productNameZh, + remainder.productNameEn, accepted)); + remainder.remaining -= accepted; + pending -= accepted; + } + if (pending != 0) throw conflict("PDA received packages exceed the frozen SKU package snapshots"); + return result; + } + private void activateDropship(String customerId, Long taskId, Map orderById, List stockReceipts) { Map> receiptsByOrder = stockReceipts.stream() @@ -190,4 +250,24 @@ public class InboundStockReceiptService { private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + + private static final class SkuRemainder { + private final String skuCode; + private final String productNameZh; + private final String productNameEn; + private long remaining; + + private SkuRemainder(String skuCode, String productNameZh, String productNameEn, long remaining) { + this.skuCode = skuCode; + this.productNameZh = productNameZh; + this.productNameEn = productNameEn; + this.remaining = remaining; + } + } + + private record SkuSnapshot(String skuCode, String productNameZh, String productNameEn, long packages) { + private static SkuSnapshot unspecified(long packages) { + return new SkuSnapshot("UNSPECIFIED", null, null, packages); + } + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java new file mode 100644 index 000000000..dcdaa2c9f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventEnvelopeValidator; +import com.baiyue.lastleg.client.EventBridgeClient; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.repository.UnloadCompletedOutboxRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** 至少一次投递海外卸柜完成事件;国内事件桥按 eventId 幂等接收。 */ +@Component +@Slf4j +public class InboundUnloadCompletedOutboxRelay { + @Resource private UnloadCompletedOutboxRepository repository; + @Resource private EventBridgeClient client; + @Resource private ObjectMapper objectMapper; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + @Value("${baiyue.firstleg.outbox-relay.lease-seconds:60}") private long leaseSeconds; + @Value("${baiyue.firstleg.outbox-relay.retry-seconds:30}") private long retrySeconds; + private final String workerId = UUID.randomUUID().toString(); + + @Scheduled(fixedDelayString = "${baiyue.firstleg.outbox-relay.fixed-delay-ms:5000}") + public void relay() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime expiredLeaseBefore = now.minusSeconds(Math.max(leaseSeconds, 10)); + for (LastlegEventOutbox candidate : repository.pending(50, now, expiredLeaseBefore)) { + if (!repository.claim(candidate.getId(), workerId, now, expiredLeaseBefore)) continue; + LastlegEventOutbox row = repository.claimed(candidate.getId(), workerId); + if (row == null) continue; + try { + EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class); + EventEnvelopeValidator.requireValid(event); + var response = client.receive(internalToken, event); + if (response == null || !response.successful() || response.data() == null + || !event.eventId().equals(response.data().eventId())) { + throw new IllegalStateException("国内事件桥未确认海外卸柜完成事件"); + } + repository.published(row.getId(), workerId, LocalDateTime.now()); + } catch (Exception exception) { + repository.retry(row.getId(), workerId, LocalDateTime.now().plusSeconds(Math.max(retrySeconds, 1)), + exception.getMessage()); + log.warn("海外卸柜完成事件投递失败: eventKey={}, reason={}", row.getEventKey(), exception.getMessage()); + } + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 588cd634d..ac12ebc30 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -2,7 +2,10 @@ package com.baiyue.lastleg.inboundplan.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanSummary; import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; @@ -36,6 +39,19 @@ public class InboundPlanService { @Resource private InboundPlanPalletAllocationMapper allocationMapper; @Resource private InboundPlanOrderMapper orderMapper; + @Transactional(readOnly = true) + public PageResult page(String customerId, InboundPlanRequests.Query query) { + if (query.createdFrom() != null && query.createdTo() != null + && query.createdFrom().isAfter(query.createdTo())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo"); + } + long total = planMapper.countRows(customerId, query); + List records = total == 0 ? List.of() + : planMapper.pageRows(customerId, query, Math.multiplyExact(query.page() - 1, query.size()), query.size()); + long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size(); + return new PageResult<>(records, query.page(), query.size(), total, pages); + } + @Transactional public InboundPlanResponse convert(String customerId, Long selectedOrderId) { List orders = orderMapper.lockOrder(customerId, selectedOrderId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java index 71d44c87b..7d7437819 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java @@ -5,12 +5,14 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.security.OverseasPermissions; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -21,12 +23,13 @@ import org.springframework.web.server.ResponseStatusException; @RequestMapping("/api/platform/v1/last-leg/inventory") @Tag(name = "Tail-leg inbound-order package inventory commands") public class InventoryCommandController { - public static final String MANAGE_PERMISSION = "lastleg:inventory:manage"; - public static final String ADJUST_PERMISSION = "lastleg:inventory:adjust"; + public static final String MANAGE_PERMISSION = OverseasPermissions.INVENTORY_EDIT; + public static final String ADJUST_PERMISSION = OverseasPermissions.INVENTORY_ADJUST; @Resource private InventoryCommandService service; @PostMapping("/hold") - @Operation(summary = "Move available inbound-order packages to hold") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EDIT + "')") + @Operation(summary = "Move available inbound-order packages to hold", description = "Required authority: " + OverseasPermissions.INVENTORY_EDIT) public ApiResponse hold(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody InventoryCommandRequests.Move request) { requirePermission(user, MANAGE_PERMISSION); @@ -34,47 +37,17 @@ public class InventoryCommandController { } @PostMapping("/release") - @Operation(summary = "Release held inbound-order packages") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EDIT + "')") + @Operation(summary = "Release held inbound-order packages", description = "Required authority: " + OverseasPermissions.INVENTORY_EDIT) public ApiResponse release(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody InventoryCommandRequests.Move request) { requirePermission(user, MANAGE_PERMISSION); return ApiResponse.success(service.release(scope(user, request.customerId()), operator(user), request)); } - @PostMapping("/allocate") - @Operation(summary = "Allocate available inbound-order packages to an outbound order") - public ApiResponse allocate(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody InventoryCommandRequests.Move request) { - requirePermission(user, MANAGE_PERMISSION); - return ApiResponse.success(service.allocate(scope(user, request.customerId()), operator(user), request)); - } - - @PostMapping("/unallocate") - @Operation(summary = "Cancel an outbound allocation") - public ApiResponse unallocate(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody InventoryCommandRequests.Move request) { - requirePermission(user, MANAGE_PERMISSION); - return ApiResponse.success(service.unallocate(scope(user, request.customerId()), operator(user), request)); - } - - @PostMapping("/pick") - @Operation(summary = "Confirm picked inbound-order packages") - public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody InventoryCommandRequests.Move request) { - requirePermission(user, MANAGE_PERMISSION); - return ApiResponse.success(service.pick(scope(user, request.customerId()), operator(user), request)); - } - - @PostMapping("/ship") - @Operation(summary = "Ship picked inbound-order packages and report whether the pallet is empty") - public ApiResponse ship(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody InventoryCommandRequests.Move request) { - requirePermission(user, MANAGE_PERMISSION); - return ApiResponse.success(service.ship(scope(user, request.customerId()), operator(user), request)); - } - @PostMapping("/adjust") - @Operation(summary = "Adjust inbound-order package inventory after a physical count") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_ADJUST + "')") + @Operation(summary = "Adjust inbound-order package inventory after a physical count", description = "Required authority: " + OverseasPermissions.INVENTORY_ADJUST) public ApiResponse adjust(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody InventoryCommandRequests.Adjustment request) { requirePermission(user, ADJUST_PERMISSION); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java index e768ccb21..bd8bb57b5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java @@ -22,6 +22,9 @@ public final class InventoryCommandRequests { @NotNull Long inboundOrderId, @NotBlank String inboundOrderNo, @NotBlank String inboundCode, + String skuCode, + String productNameZh, + String productNameEn, @NotNull Long palletId, @NotBlank String palletNo, String batchNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index e96f09a59..7d6edae29 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -175,8 +175,9 @@ public class InventoryCommandService { } private InventoryBalance balance(String customerId, InventoryCommandRequests.StockIn request) { + String skuCode = sku(request.skuCode()); InventoryBalance balance = balances.lockDimension(customerId, request.overseasWarehouseId(), - request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode()); + request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode(), skuCode); if (balance != null) return balance; balance = new InventoryBalance(); balance.setId(IdWorker.getId()); @@ -191,6 +192,9 @@ public class InventoryCommandService { balance.setInboundOrderId(request.inboundOrderId()); balance.setInboundOrderNo(request.inboundOrderNo().trim()); balance.setInboundCode(request.inboundCode().trim()); + balance.setSkuCode(skuCode); + balance.setProductNameZh(trim(request.productNameZh())); + balance.setProductNameEn(trim(request.productNameEn())); zero(balance); balance.setVersion(0L); balance.setCreatedAt(LocalDateTime.now()); @@ -200,7 +204,7 @@ public class InventoryCommandService { return balance; } catch (DuplicateKeyException exception) { InventoryBalance concurrent = balances.lockDimension(customerId, request.overseasWarehouseId(), - request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode()); + request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode(), skuCode); if (concurrent != null) return concurrent; throw exception; } @@ -314,6 +318,7 @@ public class InventoryCommandService { ledger.setPalletNo(position.getPalletNo()); ledger.setBatchNo(position.getBatchNo()); ledger.setWarehouseLocationCode(position.getWarehouseLocationCode()); + ledger.setSkuCode(balance.getSkuCode()); ledger.setBusinessType(businessType); ledger.setBusinessOrderId(balance.getInboundOrderId()); ledger.setBusinessOrderNo(trim(businessOrderNo)); @@ -434,6 +439,11 @@ public class InventoryCommandService { return value == null || value.isBlank() ? null : value.trim(); } + private static String sku(String value) { + String normalized = trim(value); + return normalized == null ? "UNSPECIFIED" : normalized; + } + private static String remarkSuffix(String remark) { String value = trim(remark); return value == null ? "" : "; " + value; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryBalance.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryBalance.java index c7e62a204..e2468fcaa 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryBalance.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryBalance.java @@ -24,6 +24,9 @@ public class InventoryBalance { private Long inboundOrderId; private String inboundOrderNo; private String inboundCode; + private String skuCode; + private String productNameZh; + private String productNameEn; private Long availablePackages; private Long holdPackages; private Long allocatedPackages; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java index 437a9dcab..16cd696b0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java @@ -12,22 +12,24 @@ public interface InventoryBalanceMapper extends BaseMapper { @Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " + "AND overseas_warehouse_id=#{overseasWarehouseId} " + "AND destination_warehouse_code=#{destinationWarehouseCode} " + - "AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} LIMIT 1") + "AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} AND sku_code=#{skuCode} LIMIT 1") InventoryBalance findDimension(@Param("customerId") String customerId, @Param("overseasWarehouseId") String overseasWarehouseId, @Param("destinationWarehouseCode") String destinationWarehouseCode, @Param("inboundOrderId") Long inboundOrderId, - @Param("inboundCode") String inboundCode); + @Param("inboundCode") String inboundCode, + @Param("skuCode") String skuCode); @Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " + "AND overseas_warehouse_id=#{overseasWarehouseId} " + "AND destination_warehouse_code=#{destinationWarehouseCode} " + - "AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} LIMIT 1 FOR UPDATE") + "AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} AND sku_code=#{skuCode} LIMIT 1 FOR UPDATE") InventoryBalance lockDimension(@Param("customerId") String customerId, @Param("overseasWarehouseId") String overseasWarehouseId, @Param("destinationWarehouseCode") String destinationWarehouseCode, @Param("inboundOrderId") Long inboundOrderId, - @Param("inboundCode") String inboundCode); + @Param("inboundCode") String inboundCode, + @Param("skuCode") String skuCode); @Select("") + List customerOptions(@Param("keyword") String keyword, @Param("limit") int limit); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOutboundMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOutboundMapper.java deleted file mode 100644 index 3f96d9478..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOutboundMapper.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.baiyue.lastleg.procurement.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baiyue.lastleg.procurement.entity.OverseasProcurementOutbound; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; - -import java.util.List; - -@Mapper -public interface OverseasProcurementOutboundMapper extends BaseMapper { - @Select("SELECT * FROM ll_overseas_procurement_outbound WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY outbound_at DESC,id DESC") - List listOrder(@Param("customerId") String customerId, - @Param("orderId") Long orderId); -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java index d955c88d8..865caf78c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -4,15 +4,16 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.masterdata.quote.service.PartnerWarehouseDirectory; import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests; import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses; import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; -import com.baiyue.lastleg.procurement.entity.OverseasProcurementOutbound; import com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; -import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOutboundMapper; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper; +import com.baiyue.lastleg.security.OverseasPermissions; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; @@ -26,21 +27,26 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import java.util.HexFormat; +import java.util.Objects; import java.util.Set; @Service public class OverseasProcurementService { - public static final String REVIEW_PERMISSION = "lastleg:overseas-procurement:review"; - public static final String MANAGE_PERMISSION = "lastleg:overseas-procurement:manage"; + public static final String REVIEW_PERMISSION = OverseasPermissions.PURCHASE_REVIEW; + public static final String MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT; public static final String PENDING_REVIEW = "PENDING_REVIEW"; public static final String APPROVED = "APPROVED"; public static final String REJECTED = "REJECTED"; private static final Set STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED); @Resource private OverseasProcurementOrderMapper orders; - @Resource private OverseasProcurementOutboundMapper outbounds; @Resource private OverseasProcurementStatusHistoryMapper histories; + @Resource private PartnerWarehouseDirectory warehouseDirectory; @Resource private ObjectMapper objectMapper; @Transactional @@ -68,7 +74,6 @@ public class OverseasProcurementService { order.setContactName(request.contactName().trim()); order.setContactPhone(request.contactPhone().trim()); order.setInboundQuantity(0L); - order.setOutboundQuantity(0L); order.setStatus(PENDING_REVIEW); order.setRemark(trim(request.remark())); order.setIdempotencyKey(key); @@ -84,10 +89,25 @@ public class OverseasProcurementService { if (concurrent != null) return replay(customerId, concurrent, requestHash); throw exception; } - history(order, null, PENDING_REVIEW, operatorId, request.remark(), now); + history(order, "CREATE", null, PENDING_REVIEW, operatorId, null, request.remark(), now); return detail(customerId, order.getOrderNo()); } + public List customerOptions(String keyword, int limit) { + if (limit < 1 || limit > 200) throw badRequest("limit must be between 1 and 200"); + return orders.customerOptions(trim(keyword), limit).stream() + .map(value -> new OverseasProcurementResponses.CustomerOption(value.getCustomerId(), + Objects.requireNonNullElse(trim(value.getCustomerName()), value.getCustomerId()))) + .toList(); + } + + public List warehouseOptions() { + return warehouseDirectory.options().stream() + .map(value -> new OverseasProcurementResponses.WarehouseOption(value.warehouseId(), + value.warehouseCode(), value.warehouseName(), value.countryCode())) + .toList(); + } + public PageResult page( String customerId, OverseasProcurementRequests.Query query) { validateQuery(query); @@ -122,10 +142,10 @@ public class OverseasProcurementService { order.getDomesticCarrier(), order.getDomesticTrackingNo(), order.getOverseasCarrier(), order.getOverseasTrackingNo(), order.getEstimatedArrivalAt(), order.getRemark(), order.getCreatedBy(), order.getReviewedBy(), order.getReviewRemark(), order.getReviewedAt(), - outbounds.listOrder(customerId, order.getId()).stream().map(this::outbound).toList(), histories.listOrder(customerId, order.getId()).stream().map(value -> - new OverseasProcurementResponses.History(value.getFromStatus(), value.getToStatus(), - value.getOperatorId(), value.getRemark(), value.getCreatedAt())).toList()); + new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(), + value.getToStatus(), value.getOperatorId(), value.getChangeDetail(), value.getRemark(), + value.getCreatedAt())).toList()); } @Transactional @@ -146,64 +166,38 @@ public class OverseasProcurementService { order.setReviewedAt(now); touch(order, now); orders.updateById(order); - history(order, PENDING_REVIEW, next, operatorId, request.remark(), now); + history(order, "REVIEW", PENDING_REVIEW, next, operatorId, null, request.remark(), now); return detail(customerId, orderNo); } @Transactional - public OverseasProcurementResponses.Detail update(String customerId, String orderNo, + public OverseasProcurementResponses.Detail update(String customerId, String orderNo, String operatorId, OverseasProcurementRequests.Update request) { OverseasProcurementOrder order = lock(customerId, orderNo); if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry"); + Map before = editableSnapshot(order); if (request.inboundQuantity() != null) { if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can receive stock"); - if (request.inboundQuantity() < value(order.getOutboundQuantity())) { - throw conflict("Inbound quantity cannot be lower than the recorded outbound quantity"); - } order.setInboundQuantity(request.inboundQuantity()); } setIfText(request.purchaseCategory(), order::setPurchaseCategory); setIfText(request.productName(), order::setProductName); - setIfPresent(request.productSpecification(), order::setProductSpecification); - setIfPresent(request.productUrl(), order::setProductUrl); - setIfPresent(request.purchaseWarehouseId(), order::setPurchaseWarehouseId); - setIfPresent(request.purchaseWarehouseName(), order::setPurchaseWarehouseName); - setIfPresent(request.domesticCarrier(), order::setDomesticCarrier); - setIfPresent(request.domesticTrackingNo(), order::setDomesticTrackingNo); - setIfPresent(request.overseasCarrier(), order::setOverseasCarrier); - setIfPresent(request.overseasTrackingNo(), order::setOverseasTrackingNo); - if (request.estimatedArrivalAt() != null) order.setEstimatedArrivalAt(request.estimatedArrivalAt()); - setIfPresent(request.remark(), order::setRemark); - touch(order, LocalDateTime.now()); + patchText(request.productSpecification(), 500, "productSpecification", order::setProductSpecification); + patchText(request.productUrl(), 2000, "productUrl", order::setProductUrl); + patchWarehouse(order, request.purchaseWarehouseId(), request.purchaseWarehouseName()); + patchText(request.domesticCarrier(), 128, "domesticCarrier", order::setDomesticCarrier); + patchText(request.domesticTrackingNo(), 128, "domesticTrackingNo", order::setDomesticTrackingNo); + patchText(request.overseasCarrier(), 128, "overseasCarrier", order::setOverseasCarrier); + patchText(request.overseasTrackingNo(), 128, "overseasTrackingNo", order::setOverseasTrackingNo); + patchDateTime(request.estimatedArrivalAt(), order::setEstimatedArrivalAt); + patchText(request.remark(), 1000, "remark", order::setRemark); + Map after = editableSnapshot(order); + if (before.equals(after)) return detail(customerId, orderNo); + LocalDateTime now = LocalDateTime.now(); + touch(order, now); orders.updateById(order); - return detail(customerId, orderNo); - } - - @Transactional - public OverseasProcurementResponses.Detail addOutbound(String customerId, String orderNo, String operatorId, - OverseasProcurementRequests.AddOutbound request) { - OverseasProcurementOrder order = lock(customerId, orderNo); - if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can ship stock"); - if (orders.addOutbound(order.getId(), customerId, request.quantity()) != 1) { - throw conflict("Outbound quantity exceeds current procurement inventory"); - } - OverseasProcurementOutbound outbound = new OverseasProcurementOutbound(); - outbound.setId(IdWorker.getId()); - outbound.setOrderId(order.getId()); - outbound.setCustomerId(customerId); - outbound.setQuantity(request.quantity()); - outbound.setOutboundNo(request.outboundNo().trim()); - outbound.setCarrier(trim(request.carrier())); - outbound.setTrackingNo(trim(request.trackingNo())); - outbound.setOutboundAt(request.outboundAt() == null ? LocalDateTime.now() : request.outboundAt()); - outbound.setRemark(trim(request.remark())); - outbound.setCreatedBy(operatorId); - outbound.setCreatedAt(LocalDateTime.now()); - try { - outbounds.insert(outbound); - } catch (DuplicateKeyException exception) { - throw conflict("Outbound number already exists for this procurement order"); - } + history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, + changeDetail(before, after), null, now); return detail(customerId, orderNo); } @@ -222,29 +216,24 @@ public class OverseasProcurementService { private OverseasProcurementResponses.Summary summary(OverseasProcurementOrder order) { long inbound = value(order.getInboundQuantity()); - long outbound = value(order.getOutboundQuantity()); return new OverseasProcurementResponses.Summary(order.getId(), order.getOrderNo(), order.getCustomerId(), order.getCustomerName(), order.getPurchaseCategory(), order.getProductName(), order.getProductSpecification(), value(order.getPurchaseQuantity()), order.getBudgetUnitPrice(), - order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), inbound, outbound, - inbound - outbound, order.getStatus(), value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt()); + order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), inbound, inbound, + order.getStatus(), value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt()); } - private OverseasProcurementResponses.Outbound outbound(OverseasProcurementOutbound value) { - return new OverseasProcurementResponses.Outbound(value.getId(), value(value.getQuantity()), value.getOutboundNo(), - value.getCarrier(), value.getTrackingNo(), value.getOutboundAt(), value.getRemark(), value.getCreatedBy(), - value.getCreatedAt()); - } - - private void history(OverseasProcurementOrder order, String from, String to, String operator, - String remark, LocalDateTime now) { + private void history(OverseasProcurementOrder order, String operationType, String from, String to, + String operator, String changeDetail, String remark, LocalDateTime now) { OverseasProcurementStatusHistory history = new OverseasProcurementStatusHistory(); history.setId(IdWorker.getId()); history.setOrderId(order.getId()); history.setCustomerId(order.getCustomerId()); + history.setOperationType(operationType); history.setFromStatus(from); history.setToStatus(to); history.setOperatorId(operator); + history.setChangeDetail(changeDetail); history.setRemark(trim(remark)); history.setCreatedAt(now); histories.insert(history); @@ -267,6 +256,91 @@ public class OverseasProcurementService { } } + private void patchWarehouse(OverseasProcurementOrder order, JsonNode warehouseId, JsonNode warehouseName) { + if (warehouseId == null) { + if (warehouseName != null) throw badRequest("purchaseWarehouseName cannot be changed without purchaseWarehouseId"); + return; + } + String id = nullableText(warehouseId, 128, "purchaseWarehouseId"); + if (id == null) { + order.setPurchaseWarehouseId(null); + order.setPurchaseWarehouseName(null); + return; + } + long numericId; + try { + numericId = Long.parseLong(id); + } catch (NumberFormatException exception) { + throw badRequest("purchaseWarehouseId must be a numeric overseas partner warehouse ID"); + } + var option = warehouseDirectory.required(numericId); + order.setPurchaseWarehouseId(option.warehouseId()); + order.setPurchaseWarehouseName(option.warehouseName()); + } + + private static void patchText(JsonNode node, int maxLength, String field, + java.util.function.Consumer setter) { + if (node != null) setter.accept(nullableText(node, maxLength, field)); + } + + private static String nullableText(JsonNode node, int maxLength, String field) { + if (node.isNull()) return null; + if (!node.isTextual()) throw badRequest(field + " must be a string or null"); + String value = trim(node.textValue()); + if (value != null && value.length() > maxLength) throw badRequest(field + " exceeds max length " + maxLength); + return value; + } + + private static void patchDateTime(JsonNode node, java.util.function.Consumer setter) { + if (node == null) return; + String value = nullableText(node, 64, "estimatedArrivalAt"); + if (value == null) { + setter.accept(null); + return; + } + try { + setter.accept(LocalDateTime.parse(value)); + } catch (DateTimeParseException exception) { + throw badRequest("estimatedArrivalAt must be an ISO local date-time or null"); + } + } + + private static Map editableSnapshot(OverseasProcurementOrder order) { + Map result = new LinkedHashMap<>(); + result.put("purchaseCategory", order.getPurchaseCategory()); + result.put("productName", order.getProductName()); + result.put("productSpecification", order.getProductSpecification()); + result.put("productUrl", order.getProductUrl()); + result.put("inboundQuantity", value(order.getInboundQuantity())); + result.put("purchaseWarehouseId", order.getPurchaseWarehouseId()); + result.put("purchaseWarehouseName", order.getPurchaseWarehouseName()); + result.put("domesticCarrier", order.getDomesticCarrier()); + result.put("domesticTrackingNo", order.getDomesticTrackingNo()); + result.put("overseasCarrier", order.getOverseasCarrier()); + result.put("overseasTrackingNo", order.getOverseasTrackingNo()); + result.put("estimatedArrivalAt", order.getEstimatedArrivalAt()); + result.put("remark", order.getRemark()); + return result; + } + + private String changeDetail(Map before, Map after) { + Map changes = new LinkedHashMap<>(); + before.forEach((field, oldValue) -> { + Object newValue = after.get(field); + if (!Objects.equals(oldValue, newValue)) { + Map pair = new LinkedHashMap<>(); + pair.put("before", oldValue); + pair.put("after", newValue); + changes.put(field, pair); + } + }); + try { + return objectMapper.writeValueAsString(changes); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot serialize procurement change detail", exception); + } + } + private static void touch(OverseasProcurementOrder order, LocalDateTime now) { order.setVersion(value(order.getVersion()) + 1); order.setUpdatedAt(now); @@ -278,9 +352,6 @@ public class OverseasProcurementService { private static void setIfText(String value, java.util.function.Consumer setter) { if (StringUtils.hasText(value)) setter.accept(value.trim()); } - private static void setIfPresent(String value, java.util.function.Consumer setter) { - if (value != null) setter.accept(trim(value)); - } private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java index 349c89f69..c4c69538e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -6,10 +6,13 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.relabel.dto.RelabelRequests; import com.baiyue.lastleg.relabel.dto.RelabelResponses; import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +import com.baiyue.lastleg.security.OverseasPermissions; +import io.swagger.v3.oas.annotations.Operation; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -19,21 +22,29 @@ public class RelabelApplicationController { @Resource private RelabelApplicationService service; @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_CREATE + "')") + @Operation(summary = "Create a relabel application", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_CREATE) public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid @RequestBody RelabelRequests.Create request) { return ApiResponse.success(service.create(scope(user, customerId), operator(user), request)); } @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") + @Operation(summary = "Page relabel applications", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_LIST) public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid RelabelRequests.Query query) { return ApiResponse.success(service.page(scope(user, customerId), query)); } @GetMapping("/{relabelNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") + @Operation(summary = "Get a relabel application", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_LIST) public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable String relabelNo) { return ApiResponse.success(service.detail(scope(user, customerId), relabelNo)); } @PutMapping("/{relabelNo}/status") + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')") + @Operation(summary = "Change relabel application status", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_STATUS) public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable String relabelNo, @Valid @RequestBody RelabelRequests.ChangeStatus request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java new file mode 100644 index 000000000..72fd13cd9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java @@ -0,0 +1,82 @@ +package com.baiyue.lastleg.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baiyue.common.event.EventTypes; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public class UnloadCompletedOutboxRepository { + @Resource private LastlegEventOutboxMapper mapper; + + public List pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) { + return mapper.selectList(new LambdaQueryWrapper() + .eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED) + .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING") + .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt) + .or().le(LastlegEventOutbox::getNextRetryAt, now)) + .or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING") + .le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore))) + .orderByAsc(LastlegEventOutbox::getCreatedAt).orderByAsc(LastlegEventOutbox::getId) + .last("LIMIT " + Math.min(Math.max(limit, 1), 100))); + } + + public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(LastlegEventOutbox::getId, id) + .eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED) + .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING") + .or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING") + .le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore))) + .set(LastlegEventOutbox::getStatus, "PROCESSING") + .set(LastlegEventOutbox::getLockedBy, worker) + .set(LastlegEventOutbox::getLockedAt, now)) == 1; + } + + public LastlegEventOutbox claimed(long id, String worker) { + return mapper.selectOne(new LambdaQueryWrapper() + .eq(LastlegEventOutbox::getId, id) + .eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED) + .eq(LastlegEventOutbox::getStatus, "PROCESSING") + .eq(LastlegEventOutbox::getLockedBy, worker)); + } + + public void published(long id, String worker, LocalDateTime now) { + mapper.update(null, claimedBase(id, worker) + .set(LastlegEventOutbox::getStatus, "PUBLISHED") + .set(LastlegEventOutbox::getPublishedAt, now) + .set(LastlegEventOutbox::getNextRetryAt, null) + .set(LastlegEventOutbox::getLockedBy, null) + .set(LastlegEventOutbox::getLockedAt, null) + .set(LastlegEventOutbox::getLastError, null)); + } + + public void retry(long id, String worker, LocalDateTime retryAt, String error) { + mapper.update(null, claimedBase(id, worker) + .setSql("retry_count = retry_count + 1") + .set(LastlegEventOutbox::getStatus, "PENDING") + .set(LastlegEventOutbox::getNextRetryAt, retryAt) + .set(LastlegEventOutbox::getLockedBy, null) + .set(LastlegEventOutbox::getLockedAt, null) + .set(LastlegEventOutbox::getLastError, truncate(error))); + } + + private LambdaUpdateWrapper claimedBase(long id, String worker) { + return new LambdaUpdateWrapper() + .eq(LastlegEventOutbox::getId, id) + .eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED) + .eq(LastlegEventOutbox::getStatus, "PROCESSING") + .eq(LastlegEventOutbox::getLockedBy, worker); + } + + private static String truncate(String value) { + String safe = value == null || value.isBlank() ? "unknown" : value.replaceAll("[\\r\\n\\t]+", " "); + return safe.length() > 500 ? safe.substring(0, 500) : safe; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java new file mode 100644 index 000000000..910f94a98 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.security; + +/** Canonical Admin authorities for fourth-phase overseas APIs. */ +public final class OverseasPermissions { + public static final String LAST_MILE_ORDER_LIST = "platform:overseas-warehouse:last-mile-order:list"; + public static final String LAST_MILE_ORDER_EDIT = "platform:overseas-warehouse:last-mile-order:edit"; + public static final String LAST_MILE_ORDER_EXPORT = "platform:overseas-warehouse:last-mile-order:export"; + public static final String INBOUND_ORDER_LIST = "platform:overseas-warehouse:inbound-order:list"; + public static final String INBOUND_ORDER_CREATE = "platform:overseas-warehouse:inbound-order:create"; + public static final String INBOUND_ORDER_EDIT = "platform:overseas-warehouse:inbound-order:edit"; + public static final String RELABEL_ORDER_LIST = "platform:overseas-warehouse:relabel-order:list"; + public static final String RELABEL_ORDER_CREATE = "platform:overseas-warehouse:relabel-order:create"; + public static final String RELABEL_ORDER_STATUS = "platform:overseas-warehouse:relabel-order:status"; + public static final String INVENTORY_LIST = "platform:overseas-warehouse:inventory:list"; + public static final String INVENTORY_EXPORT = "platform:overseas-warehouse:inventory:export"; + public static final String INVENTORY_EDIT = "platform:overseas-warehouse:inventory:edit"; + public static final String INVENTORY_ADJUST = "platform:overseas-warehouse:inventory:adjust"; + public static final String PURCHASE_LIST = "platform:overseas-business:purchase:list"; + public static final String PURCHASE_EDIT = "platform:overseas-business:purchase:edit"; + public static final String PURCHASE_REVIEW = "platform:overseas-business:purchase:review"; + public static final String TRUCK_LIST = "platform:overseas-business:truck:list"; + public static final String TRUCK_CREATE = "platform:overseas-business:truck:create"; + public static final String TRUCK_EDIT = "platform:overseas-business:truck:edit"; + public static final String TRUCK_STATUS = "platform:overseas-business:truck:status"; + public static final String LOOSEBOARD_LIST = "platform:overseas-business:looseboard:list"; + public static final String LOOSEBOARD_CREATE = "platform:overseas-business:looseboard:create"; + public static final String LOOSEBOARD_EDIT = "platform:overseas-business:looseboard:edit"; + public static final String LOOSEBOARD_STATUS = "platform:overseas-business:looseboard:status"; + public static final String QUOTE_LIST = "platform:overseas-master-data:quote:list"; + public static final String QUOTE_CREATE = "platform:overseas-master-data:quote:create"; + public static final String QUOTE_EDIT = "platform:overseas-master-data:quote:edit"; + public static final String QUOTE_STATUS = "platform:overseas-master-data:quote:status"; + public static final String QUOTE_EXPORT = "platform:overseas-master-data:quote:export"; + public static final String EXCHANGE_RATE_LIST = "platform:overseas-master-data:exchange-rate:list"; + public static final String EXCHANGE_RATE_CREATE = "platform:overseas-master-data:exchange-rate:create"; + public static final String EXCHANGE_RATE_EDIT = "platform:overseas-master-data:exchange-rate:edit"; + public static final String EXCHANGE_RATE_STATUS = "platform:overseas-master-data:exchange-rate:status"; + public static final String EXCHANGE_RATE_EXPORT = "platform:overseas-master-data:exchange-rate:export"; + public static final String VEHICLE_LIST = "platform:overseas-master-data:vehicle:list"; + public static final String VEHICLE_CREATE = "platform:overseas-master-data:vehicle:create"; + public static final String VEHICLE_EDIT = "platform:overseas-master-data:vehicle:edit"; + public static final String VEHICLE_STATUS = "platform:overseas-master-data:vehicle:status"; + public static final String VEHICLE_EXPORT = "platform:overseas-master-data:vehicle:export"; + + private OverseasPermissions() { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 414d2067f..d84c1c506 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -6,11 +6,6 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; -import com.baiyue.lastleg.dropship.entity.DropshipCarton; -import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; -import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper; -import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; -import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; @@ -29,8 +24,6 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.Objects; -import java.util.Map; -import java.util.stream.Collectors; @Service public class LastlegOrderService { @@ -41,9 +34,6 @@ public class LastlegOrderService { @Resource private LastlegOrderMapper orderMapper; @Resource private LastlegOrderDetailMapper detailMapper; @Resource private LastlegOrderStatusHistoryMapper historyMapper; - @Resource private DropshipOrderProfileMapper dropshipProfiles; - @Resource private DropshipSkuInventoryMapper dropshipSkuInventory; - @Resource private DropshipCartonMapper dropshipCartons; public PageResult page(String customerId, LastlegOrderRequests.Query query) { Page page = orderMapper.selectPage( @@ -60,23 +50,9 @@ public class LastlegOrderService { new LambdaQueryWrapper() .eq(LastlegOrderStatusHistory::getOrderId, orderId) .orderByAsc(LastlegOrderStatusHistory::getCreatedAt)); - List pallets = dropshipPallets(customerId, order); - return new LastlegOrderDetailResponse(order, cargo, history, pallets); - } - - private List dropshipPallets(String customerId, LastlegOrder order) { - if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) return List.of(); - var profiles = dropshipProfiles.listOrder(customerId, order.getId()); - if (profiles.isEmpty()) return List.of(); - Map> skuByProfile = dropshipSkuInventory - .listOrder(customerId, order.getId()).stream().collect(Collectors.groupingBy(DropshipSkuInventory::getProfileId)); - Map> cartonsByProfile = dropshipCartons - .listOrder(customerId, order.getId()).stream().collect(Collectors.groupingBy(DropshipCarton::getProfileId)); - return profiles.stream().map(profile -> new LastlegOrderDetailResponse.DropshipPallet( - profile.getId(), profile.getSourcePalletNo(), profile.getWarehousePalletId(), profile.getPalletNo(), - profile.getInventoryPositionId(), profile.getStatus(), - skuByProfile.getOrDefault(profile.getId(), List.of()), - cartonsByProfile.getOrDefault(profile.getId(), List.of()))).toList(); + return new LastlegOrderDetailResponse(summary(order), cargo, history.stream() + .map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(), + value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList()); } @Transactional @@ -126,20 +102,16 @@ public class LastlegOrderService { List orders = orderMapper.selectList(filter(customerId, query) .orderByDesc(LastlegOrder::getCreatedAt).last("LIMIT 10000")); List lines = new ArrayList<>(); - lines.add("ID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,已拣箱数,已出库箱数,剩余箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,出库单号,派送单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)"); + lines.add("ID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)"); for (LastlegOrder order : orders) { lines.add(String.join(",", List.of( csv(order.getId()), csv(order.getOrderNo()), csv(order.getOrderType()), csv(order.getInboundCode()), csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()), csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()), csv(order.getContainerNo()), csv(order.getContainerStatus()), csv(order.getAbnormal()), csv(order.getDetained()), - csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getPickedPackageCount()), - csv(order.getShippedPackageCount()), csv(Math.max(0, Objects.requireNonNullElse(order.getPackageCount(), 0) - - Objects.requireNonNullElse(order.getShippedPackageCount(), 0))), - csv(order.getVolumeCbm()), csv(order.getProductQuantity()), + csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getVolumeCbm()), csv(order.getProductQuantity()), csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()), - csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getOutboundOrderNo()), - csv(order.getDeliveryOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()), + csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()), csv(order.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()), csv(order.getCustomerRemark()), csv(order.getWarehouseRemark())))); } @@ -184,13 +156,11 @@ public class LastlegOrderService { o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerName(), o.getOverseasWarehouseId(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(), o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(), - o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getPickedPackageCount(), - o.getShippedPackageCount(), Math.max(0, Objects.requireNonNullElse(o.getPackageCount(), 0) - - Objects.requireNonNullElse(o.getShippedPackageCount(), 0)), o.getVolumeCbm(), + o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(), o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(), - o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getOutboundOrderNo(), o.getDeliveryOrderNo(), - o.getRelabelOrderNo(), o.getRelabelStatus(), o.getPalletMergeFlag(), o.getCurrentStatus(), o.getSubOrderStatus(), - o.getPrivateRemark(), o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt()); + o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getRelabelOrderNo(), o.getRelabelStatus(), + o.getPalletMergeFlag(), o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(), + o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt()); } private static String csv(Object value) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index 5abcb23ce..fa03464e1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -394,12 +394,17 @@ public class WarehouseTransferService { if (!"SHIPPED".equals(allocation.getStatus())) throw conflict("Transfer allocation is not awaiting receipt"); String operationKey = "WAREHOUSE_TRANSFER_RECEIPT:" + transferNo + ":" + allocation.getInventoryPositionId(); + InventoryBalance sourceBalance = inventoryCommands.lockBalance(customerId, allocation.getInventoryBalanceId()); + if (sourceBalance == null || !customerId.equals(sourceBalance.getCustomerId())) { + throw conflict("Source inventory balance is missing for warehouse transfer receipt"); + } InventoryCommandResponse result = inventory.stockIn(customerId, operatorId, new InventoryCommandRequests.StockIn(customerId, null, request.destinationOverseasWarehouseId().trim(), trim(request.destinationOverseasWarehouseCode()), trim(request.destinationOverseasWarehouseName()), trim(request.destinationWarehouseId()), request.destinationWarehouseCode().trim(), trim(request.destinationWarehouseName()), transfer.getLastlegOrderId(), transfer.getLastlegOrderNo(), transferNo, + sourceBalance.getSkuCode(), sourceBalance.getProductNameZh(), sourceBalance.getProductNameEn(), request.destinationPalletId(), request.destinationPalletNo().trim(), trim(request.batchNo()) == null ? transferNo : request.batchNo().trim(), request.warehouseLocationId(), trim(request.warehouseLocationCode()), allocation.getPackageCount(), transferNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java index 2c30b5907..2435b98c0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java @@ -45,7 +45,7 @@ public class MerchantUsTruckOrderController { @PatchMapping("/{orderNo}") public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Update request) { - return ApiResponse.success(service.update(customer(user), orderNo, request)); + return ApiResponse.success(service.update(customer(user), orderNo, operator(user), request)); } private String customer(AuthenticatedUser user) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java index 3dba2f252..b1d1b1803 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java @@ -6,10 +6,13 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses; import com.baiyue.lastleg.trucking.service.UsTruckOrderService; +import com.baiyue.lastleg.security.OverseasPermissions; +import io.swagger.v3.oas.annotations.Operation; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -26,32 +29,59 @@ public class PlatformUsTruckOrderController { @Resource private UsTruckOrderService service; @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_LIST + "')") + @Operation(summary = "Page local truck orders", description = "Required authority: " + OverseasPermissions.TRUCK_LIST) public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid UsTruckOrderRequests.Query query) { requirePlatform(user); return ApiResponse.success(service.page(null, query)); } + @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_CREATE + "')") + @Operation(summary = "Create a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_CREATE) + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @Valid @RequestBody UsTruckOrderRequests.Create request) { + requirePermission(user, OverseasPermissions.TRUCK_CREATE); + return ApiResponse.success(service.create(requiredCustomer(customerId), operator(user), request)); + } + @GetMapping("/{orderNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_LIST + "')") + @Operation(summary = "Get a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_LIST) public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @PathVariable String orderNo) { requirePlatform(user); return ApiResponse.success(service.detail(requiredCustomer(customerId), orderNo)); } + @PatchMapping("/{orderNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_EDIT + "')") + @Operation(summary = "Update a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_EDIT) + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String orderNo, + @Valid @RequestBody UsTruckOrderRequests.Update request) { + requirePermission(user, OverseasPermissions.TRUCK_EDIT); + return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, operator(user), request)); + } + @PatchMapping("/{orderNo}/status") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + @Operation(summary = "Change local truck order status", description = "Required authority: " + OverseasPermissions.TRUCK_STATUS) public ApiResponse changeStatus(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.ChangeStatus request) { - requirePermission(user); + requirePermission(user, OverseasPermissions.TRUCK_STATUS); return ApiResponse.success(service.changeStatus(requiredCustomer(customerId), orderNo, operator(user), request)); } @PostMapping("/{orderNo}/receive-pallets") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + @Operation(summary = "Receive pallets for a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_STATUS) public ApiResponse receivePallets(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.ReceivePallets request) { - requirePermission(user); + requirePermission(user, OverseasPermissions.TRUCK_STATUS); return ApiResponse.success(service.receivePallets(requiredCustomer(customerId), orderNo, operator(user), request)); } @@ -62,11 +92,10 @@ public class PlatformUsTruckOrderController { } } - private void requirePermission(AuthenticatedUser user) { + private void requirePermission(AuthenticatedUser user, String permission) { requirePlatform(user); - if (user.permissions() == null || !user.permissions().contains(UsTruckOrderService.MANAGE_PERMISSION)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, - UsTruckOrderService.MANAGE_PERMISSION + " permission is required"); + if (user.permissions() == null || !user.permissions().contains(permission)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, permission + " permission is required"); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java index 4c6453f2d..4dd11a600 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java @@ -12,6 +12,7 @@ import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory; import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper; import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper; import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper; +import com.baiyue.lastleg.security.OverseasPermissions; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -35,7 +36,7 @@ import java.util.function.Consumer; @Service public class UsTruckOrderService { - public static final String MANAGE_PERMISSION = "lastleg:us-trucking:manage"; + public static final String MANAGE_PERMISSION = OverseasPermissions.TRUCK_STATUS; public static final String PENDING_REVIEW = "PENDING_REVIEW"; /** Kept for source compatibility; new orders use PENDING_REVIEW. */ @Deprecated public static final String PENDING_CONFIRMATION = PENDING_REVIEW; @@ -133,7 +134,8 @@ public class UsTruckOrderService { } @Transactional - public UsTruckOrderResponses.Detail update(String customerId, String orderNo, UsTruckOrderRequests.Update request) { + public UsTruckOrderResponses.Detail update(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.Update request) { UsTruckOrder order = lock(customerId, orderNo); if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Only pending review orders can be edited"); @@ -147,7 +149,9 @@ public class UsTruckOrderService { if (request.budgetAmount() != null) order.setBudgetAmount(request.budgetAmount()); if (request.currency() != null) order.setCurrency(defaultCurrency(request.currency())); setText(request.contactName(), order::setContactName); setText(request.contactInfo(), order::setContactInfo); if (request.requestedDate() != null) order.setRequestedDate(request.requestedDate()); setPresent(request.remark(), order::setRemark); - touch(order); orders.updateById(order); return detail(customerId, orderNo); + touch(order); orders.updateById(order); + history(order, order.getStatus(), order.getStatus(), operatorId, "UPDATE", LocalDateTime.now()); + return detail(customerId, orderNo); } @Transactional diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 250cdcb8d..dc6c7d504 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -18,7 +18,7 @@ spring: - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} username: ${NACOS_USERNAME:nacos} password: ${NACOS_PASSWORD:nacos} discovery: @@ -35,6 +35,16 @@ spring: management.endpoints.web.exposure.include: health,info,prometheus baiyue: + clients: + domestic-warehouse: + url: ${DOMESTIC_WAREHOUSE_URL:} + cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} + firstleg: + event-bridge-url: ${FIRSTLEG_EVENT_BRIDGE_URL:} + outbox-relay: + fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000} + lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} + retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30} security: internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} jwt-mode: jwks diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 8e3682ab8..116171234 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -519,11 +519,82 @@ CREATE TABLE IF NOT EXISTS ll_event_outbox ( event_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', payload_json LONGTEXT NOT NULL COMMENT '事件载荷JSON', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', published_at DATETIME(3) NULL COMMENT '发布时间', + locked_at DATETIME(3) NULL COMMENT '投递租约获取时间', locked_by VARCHAR(128) NULL COMMENT '投递租约持有者', last_error VARCHAR(1000) NULL COMMENT '最后一次发布错误', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_event_outbox_key (event_key), KEY idx_ll_event_outbox_publish (status, next_retry_at, created_at) ) ENGINE=InnoDB COMMENT='尾程跨区业务事件发件箱'; +CREATE TABLE IF NOT EXISTS ll_overseas_quote ( + id BIGINT NOT NULL COMMENT '报价ID', name VARCHAR(128) NOT NULL COMMENT '报价名称', + warehouse_id BIGINT NOT NULL COMMENT '国内平台海外合作仓ID', warehouse_code VARCHAR(64) NOT NULL COMMENT '合作仓编码快照', + warehouse_name VARCHAR(128) NOT NULL COMMENT '合作仓名称快照', country_code VARCHAR(2) NOT NULL COMMENT '国家二字码快照', + provider_name VARCHAR(128) NOT NULL COMMENT '报价方', currency CHAR(3) NOT NULL COMMENT '币种', + effective_start DATE NOT NULL COMMENT '生效开始日期', effective_end DATE NOT NULL COMMENT '生效结束日期', + version_code VARCHAR(64) NOT NULL COMMENT '业务版本号', status VARCHAR(16) NOT NULL DEFAULT 'INACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_warehouse_provider_version (warehouse_id,provider_name,version_code), + KEY idx_ll_quote_page (status,updated_at,id), KEY idx_ll_quote_effective (warehouse_id,provider_name,currency,effective_start,effective_end) +) ENGINE=InnoDB COMMENT='海外仓报价主表'; + +CREATE TABLE IF NOT EXISTS ll_overseas_quote_fee ( + id BIGINT NOT NULL COMMENT '费用明细ID', quote_id BIGINT NOT NULL COMMENT '报价ID', sequence_no INT NOT NULL COMMENT '显示顺序', + item VARCHAR(128) NOT NULL COMMENT '费用项目', billing_unit VARCHAR(64) NOT NULL COMMENT '计费单位', + unit_price DECIMAL(18,4) NOT NULL COMMENT '单价', minimum_charge DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低收费', + remark VARCHAR(500) NULL COMMENT '备注', PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_fee_sequence (quote_id,sequence_no), + KEY idx_ll_quote_fee_quote (quote_id) +) ENGINE=InnoDB COMMENT='海外仓报价仓库费用'; + +CREATE TABLE IF NOT EXISTS ll_overseas_quote_transit_price ( + id BIGINT NOT NULL COMMENT '中转价格ID', quote_id BIGINT NOT NULL COMMENT '报价ID', sequence_no INT NOT NULL COMMENT '显示顺序', + fba_warehouse_code VARCHAR(64) NOT NULL COMMENT 'FBA仓编码', delivery_method VARCHAR(64) NOT NULL COMMENT '报价运输方式', + estimated_miles DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '预估英里数', price DECIMAL(18,4) NOT NULL COMMENT '报价金额', + minimum_charge DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低收费', remark VARCHAR(500) NULL COMMENT '备注', + PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_transit_sequence (quote_id,sequence_no), + KEY idx_ll_quote_transit_quote (quote_id) +) ENGINE=InnoDB COMMENT='海外仓报价中转价格明细'; + +CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate ( + id BIGINT NOT NULL COMMENT '月度汇率ID', rate_month CHAR(7) NOT NULL COMMENT '汇率月份,格式yyyy-MM', + base_currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '基准币种', quote_currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '报价币种', + usd_to_cny_average DECIMAL(18,8) NOT NULL COMMENT '美元兑人民币月平均汇率', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_exchange_rate_month (rate_month), + KEY idx_ll_exchange_rate_page (status,rate_month) +) ENGINE=InnoDB COMMENT='美元兑人民币月度平均汇率'; + +CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate_history ( + id BIGINT NOT NULL COMMENT '汇率变更历史ID', exchange_rate_id BIGINT NOT NULL COMMENT '月度汇率ID', + before_rate DECIMAL(18,8) NULL COMMENT '变更前汇率', after_rate DECIMAL(18,8) NOT NULL COMMENT '变更后汇率', + before_status VARCHAR(16) NULL COMMENT '变更前状态', after_status VARCHAR(16) NOT NULL COMMENT '变更后状态', + operator_id VARCHAR(128) NOT NULL COMMENT '操作人', remark VARCHAR(1000) NULL COMMENT '变更说明', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', PRIMARY KEY (id), + KEY idx_ll_exchange_rate_history (exchange_rate_id,created_at) +) ENGINE=InnoDB COMMENT='月度汇率变更历史'; + +CREATE TABLE IF NOT EXISTS ll_vehicle ( + id BIGINT NOT NULL COMMENT '车辆档案ID', plate_number VARCHAR(64) NOT NULL COMMENT '车牌号', + model VARCHAR(128) NOT NULL COMMENT '车型', fuel_consumption_per100_km DECIMAL(10,2) NOT NULL COMMENT '百公里油耗,单位升', + mileage_km BIGINT NOT NULL COMMENT '当前公里码,单位公里', driver_or_dispatch_company VARCHAR(128) NULL COMMENT '司机姓名或外派单位', + phone VARCHAR(64) NULL COMMENT '司机或外派单位联系电话', region VARCHAR(16) NOT NULL COMMENT '区域:DOMESTIC/OVERSEAS', + ownership VARCHAR(16) NOT NULL COMMENT '车辆归属:OWNED/DISPATCHED', + status VARCHAR(32) NOT NULL COMMENT '状态:IN_SERVICE/DISPATCHED/MAINTENANCE/INACTIVE/RETURNED', + remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_vehicle_plate (plate_number), + KEY idx_ll_vehicle_page (ownership,region,status,updated_at,id) +) ENGINE=InnoDB COMMENT='平台车辆主数据'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_at DATETIME(3) NULL COMMENT ''投递租约获取时间'' AFTER published_at')); +PREPARE ll_outbox_lease_stmt FROM @ddl; EXECUTE ll_outbox_lease_stmt; DEALLOCATE PREPARE ll_outbox_lease_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_by'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_by VARCHAR(128) NULL COMMENT ''投递租约持有者'' AFTER locked_at')); +PREPARE ll_outbox_lease_stmt FROM @ddl; EXECUTE ll_outbox_lease_stmt; DEALLOCATE PREPARE ll_outbox_lease_stmt; + CREATE TABLE IF NOT EXISTS ll_inbound_pda_operation ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', allocation_id BIGINT NULL COMMENT '分配记录ID', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', @@ -604,6 +675,9 @@ CREATE TABLE IF NOT EXISTS ll_inventory_balance ( inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_order_no VARCHAR(128) NULL COMMENT '入仓单号快照', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码;无SKU的专项库存使用UNSPECIFIED', + product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照', + product_name_en VARCHAR(255) NULL COMMENT '英文品名快照', available_packages BIGINT NOT NULL DEFAULT 0 COMMENT '可用箱数', hold_packages BIGINT NOT NULL DEFAULT 0 COMMENT '冻结箱数', allocated_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已分配箱数', picked_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已拣箱数', in_transit_packages BIGINT NOT NULL DEFAULT 0 COMMENT '在途箱数', receiving_packages BIGINT NOT NULL DEFAULT 0 COMMENT '收货处理中箱数', @@ -612,10 +686,11 @@ CREATE TABLE IF NOT EXISTS ll_inventory_balance ( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (id), - UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code), + UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code,sku_code), KEY idx_ll_inventory_list (customer_id,overseas_warehouse_id,updated_at), - KEY idx_ll_inventory_inbound (customer_id,inbound_code) -) ENGINE=InnoDB COMMENT='入仓单维度实时库存余额'; + KEY idx_ll_inventory_inbound (customer_id,inbound_code), + KEY idx_ll_inventory_sku (customer_id,sku_code,updated_at) +) ENGINE=InnoDB COMMENT='入仓单与SKU维度实时库存余额'; CREATE TABLE IF NOT EXISTS ll_inventory_position ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', @@ -669,13 +744,15 @@ CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', allocation_id BIGINT NOT NULL COMMENT '分配记录ID', line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照', + product_name_en VARCHAR(255) NULL COMMENT '英文品名快照', warehouse_pallet_id BIGINT NOT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(64) NOT NULL COMMENT '托盘号', warehouse_location_id BIGINT NULL COMMENT '仓库库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '仓库库位编码', batch_no VARCHAR(128) NOT NULL COMMENT '库存批次号', received_packages BIGINT NOT NULL COMMENT '实收箱数', inventory_balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位记录ID', inventory_operation_key VARCHAR(255) NOT NULL COMMENT '库存操作幂等键', received_at DATETIME(3) NOT NULL COMMENT '实收时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY (id), UNIQUE KEY uk_ll_stock_receipt_task_allocation (customer_id,task_id,allocation_id), + PRIMARY KEY (id), UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (customer_id,task_id,allocation_id,sku_code), KEY idx_ll_stock_receipt_plan (customer_id,plan_id,line_id), KEY idx_ll_stock_receipt_inventory (inventory_balance_id,inventory_position_id) ) ENGINE=InnoDB COMMENT='入仓PDA库存实收快照'; @@ -1080,7 +1157,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', receiving_address VARCHAR(1000) NULL COMMENT '收货地址', contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', - inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计入库数量', outbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计出库数量', + inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '采购单据累计入库数量', domestic_carrier VARCHAR(128) NULL COMMENT '境内承运商', domestic_tracking_no VARCHAR(128) NULL COMMENT '境内物流单号', overseas_carrier VARCHAR(128) NULL COMMENT '海外承运商', overseas_tracking_no VARCHAR(128) NULL COMMENT '海外物流单号', estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', @@ -1095,22 +1172,14 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at) ) ENGINE=InnoDB COMMENT='海外采购订单'; -CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound ( - id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', - quantity BIGINT NOT NULL COMMENT '数量', outbound_no VARCHAR(128) NOT NULL COMMENT '出库单号', carrier VARCHAR(128) NULL COMMENT '承运商', - tracking_no VARCHAR(128) NULL COMMENT '物流跟踪号', outbound_at DATETIME(3) NOT NULL COMMENT '出库时间', - remark VARCHAR(1000) NULL COMMENT '备注', created_by VARCHAR(128) NULL COMMENT '创建人ID', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), - UNIQUE KEY uk_ll_overseas_procurement_outbound_no(customer_id,order_id,outbound_no), - KEY idx_ll_overseas_procurement_outbound_order(customer_id,order_id,outbound_at) -) ENGINE=InnoDB COMMENT='海外采购出库记录'; - CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history ( id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE', from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', - remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_overseas_procurement_history(customer_id,order_id,created_at) -) ENGINE=InnoDB COMMENT='海外采购审核历史'; +) ENGINE=InnoDB COMMENT='海外采购操作流水'; CREATE TABLE IF NOT EXISTS ll_us_truck_order ( id BIGINT NOT NULL COMMENT '主键ID', @@ -1183,3 +1252,34 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history ( PRIMARY KEY(id), KEY idx_ll_us_truck_history(customer_id,order_id,created_at) ) ENGINE=InnoDB COMMENT='美国卡车订单状态历史'; + +CREATE TABLE IF NOT EXISTS ll_local_looseboard_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载', + pallet_required VARCHAR(8) NOT NULL COMMENT '是否需要打盘:YES或NO', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', + origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址', + destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的详细地址、码头或仓号', + container_type VARCHAR(32) NULL COMMENT '柜型', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数', + total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', + total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', + budget_amount DECIMAL(18,2) NULL COMMENT '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种', + contact_name VARCHAR(128) NULL COMMENT '联系人姓名', contact_info VARCHAR(128) NULL COMMENT '联系人联系方式', + requested_date DATE NULL COMMENT '要求服务日期', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态', + remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', + request_hash CHAR(64) NOT NULL COMMENT '创建请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', + version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_local_looseboard_no(customer_id,order_no), + UNIQUE KEY uk_ll_local_looseboard_key(customer_id,idempotency_key), + KEY idx_ll_local_looseboard_list(customer_id,status,created_at), + KEY idx_ll_local_looseboard_date(customer_id,requested_date,created_at) +) ENGINE=InnoDB COMMENT='本土散板订单'; + +CREATE TABLE IF NOT EXISTS ll_local_looseboard_history ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、UPDATE或STATUS', from_status VARCHAR(32) NULL COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + KEY idx_ll_local_looseboard_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='本土散板操作流水'; diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml new file mode 100644 index 000000000..2d1d28902 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -0,0 +1,312 @@ +openapi: 3.0.3 +info: + title: Lastleg Overseas Business API + version: 1.0.0 +paths: + /api/platform/v1/last-leg/overseas-procurements: + get: + summary: 分页查询海外采购订单 + description: 权限 platform:overseas-business:purchase:list;customerId 为空时查询全部客户。 + security: [{ bearerAuth: [] }] + parameters: + - { name: customerId, in: query, schema: { type: string } } + - { name: page, in: query, required: true, schema: { type: integer, minimum: 1 } } + - { name: size, in: query, required: true, schema: { type: integer, minimum: 1, maximum: 200 } } + - { name: orderNo, in: query, schema: { type: string } } + - { name: customerKeyword, in: query, schema: { type: string } } + - { name: purchaseCategory, in: query, schema: { type: string } } + - { name: productName, in: query, schema: { type: string } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/ProcurementStatus' } } + - { name: purchaseWarehouseId, in: query, schema: { type: string } } + responses: { '200': { description: 统一 ApiResponse 包装的采购订单分页 } } + /api/platform/v1/last-leg/overseas-procurements/customer-options: + get: + summary: 查询已有采购订单的客户候选 + description: 权限 platform:overseas-business:purchase:list;候选来自尾程库采购订单客户快照,不跨库查询 IAM。 + security: [{ bearerAuth: [] }] + parameters: + - { name: keyword, in: query, schema: { type: string } } + - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } } + responses: + '200': + description: 客户候选 + content: { application/json: { schema: { $ref: '#/components/schemas/CustomerOptionResponse' } } } + /api/platform/v1/last-leg/overseas-procurements/warehouse-options: + get: + summary: 查询启用的海外合作采购仓候选 + description: 权限 platform:overseas-business:purchase:list;复用服务端缓存目录,浏览器不访问国内内部 API。 + security: [{ bearerAuth: [] }] + responses: + '200': + description: 采购仓候选 + content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseOptionResponse' } } } + /api/platform/v1/last-leg/overseas-procurements/{orderNo}: + parameters: + - { name: orderNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + get: + summary: 查询海外采购订单详情及操作流水 + description: 权限 platform:overseas-business:purchase:list。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } } + patch: + summary: 编辑海外采购订单 + description: 权限 platform:overseas-business:purchase:edit;可选字段缺失表示不修改,显式 null 或空串表示清空。采购仓名称由 warehouse ID 对应候选快照决定。采购出库不属于本 API。 + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementUpdate' } } } + responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 }, '409': { description: 乐观锁冲突或状态不允许 } } + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/review: + post: + summary: 审核海外采购订单 + description: 权限 platform:overseas-business:purchase:review。 + security: [{ bearerAuth: [] }] + parameters: + - { name: orderNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + requestBody: + required: true + content: { application/json: { schema: { type: object, required: [decision], properties: { decision: { type: string, enum: [APPROVE, REJECT] }, remark: { type: string, nullable: true, maxLength: 1000 } } } } } + responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } } + /api/merchant/v1/last-leg/overseas-procurements: + post: + summary: 客户创建海外采购订单 + description: 客户 ID 只取认证身份;新单固定进入 PENDING_REVIEW。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementCreate' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } } + get: + summary: 客户分页查询自己的海外采购订单 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 统一 ApiResponse 包装的采购订单分页 } } + /api/merchant/v1/last-leg/overseas-procurements/{orderNo}: + get: + summary: 客户查询自己的海外采购订单详情及流水 + security: [{ bearerAuth: [] }] + parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } } + /api/platform/v1/last-leg/us-truck-orders: + get: + summary: 分页查询本土卡车订单 + description: 权限 platform:overseas-business:truck:list。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 统一 ApiResponse 包装的卡车订单分页 } } + post: + summary: 平台为指定客户创建本土卡车订单 + description: 权限 platform:overseas-business:truck:create;customerId 必填且只作为可信服务端隔离键。 + security: [{ bearerAuth: [] }] + parameters: [{ name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCreate' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}: + parameters: + - { name: orderNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + get: + summary: 查询本土卡车订单详情 + description: 权限 platform:overseas-business:truck:list。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } } + patch: + summary: 平台普通编辑本土卡车订单 + description: 权限 platform:overseas-business:truck:edit;仅待审核订单可编辑,不能修改状态。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckUpdate' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}/status: + patch: + summary: 推进本土卡车订单状态 + description: 权限 platform:overseas-business:truck:status;普通编辑接口不能修改状态。 + security: [{ bearerAuth: [] }] + parameters: + - { name: orderNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + requestBody: { required: true, content: { application/json: { schema: { type: object, required: [version, status], properties: { version: { type: integer, format: int64, minimum: 0 }, status: { $ref: '#/components/schemas/TruckStatus' }, remark: { type: string, nullable: true, maxLength: 1000 } } } } } } + responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 }, '409': { description: 非法状态迁移 } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}/receive-pallets: + post: + summary: 记录本土卡车订单实收托盘 + description: 权限 platform:overseas-business:truck:status;幂等记录实收,不创建海外仓出库或派送单。 + security: [{ bearerAuth: [] }] + parameters: + - { name: orderNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } } + /api/platform/v1/last-leg/local-looseboard-orders: + get: + summary: 分页查询本土散板订单 + description: 权限 platform:overseas-business:looseboard:list。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 统一 ApiResponse 包装的散板订单分页 } } + post: + summary: 创建本土散板订单 + description: 权限 platform:overseas-business:looseboard:create;独立稳定子域,不复用卡车端点。 + security: [{ bearerAuth: [] }] + parameters: [{ name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardCreate' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的散板订单详情 } } + /api/platform/v1/last-leg/local-looseboard-orders/{orderNo}: + parameters: + - { name: orderNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + get: + summary: 查询本土散板订单详情及操作流水 + description: 权限 platform:overseas-business:looseboard:list。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 统一 ApiResponse 包装的散板订单详情 } } + put: + summary: 全量更新待接单的本土散板订单 + description: 权限 platform:overseas-business:looseboard:edit;可空字段传 null 或空串即清空;状态由独立端点推进。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardUpdate' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的散板订单详情 }, '409': { description: 版本或状态冲突 } } + /api/platform/v1/last-leg/local-looseboard-orders/{orderNo}/status: + patch: + summary: 推进或取消本土散板订单 + description: 权限 platform:overseas-business:looseboard:status;只允许 PENDING_ACCEPTANCE→DISPATCHED→IN_TRANSIT→COMPLETED,未完成节点按规则取消。 + security: [{ bearerAuth: [] }] + parameters: + - { name: orderNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + requestBody: { required: true, content: { application/json: { schema: { type: object, required: [version, status], properties: { version: { type: integer, format: int64, minimum: 0 }, status: { $ref: '#/components/schemas/LooseboardStatus' }, remark: { type: string, nullable: true, maxLength: 1000 } } } } } } + responses: { '200': { description: 统一 ApiResponse 包装的散板订单详情 }, '409': { description: 非法状态迁移 } } +components: + schemas: + ProcurementStatus: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] } + ProcurementCreate: + type: object + required: [purchaseCategory, productName, purchaseQuantity, contactName, contactPhone, idempotencyKey] + properties: + customerName: { type: string, nullable: true, maxLength: 255 } + purchaseCategory: { type: string, maxLength: 128 } + productName: { type: string, maxLength: 255 } + productSpecification: { type: string, nullable: true, maxLength: 500 } + productUrl: { type: string, nullable: true, maxLength: 2000 } + purchaseQuantity: { type: integer, format: int64, minimum: 1 } + budgetUnitPrice: { type: number, nullable: true, minimum: 0 } + currency: { type: string, minLength: 3, maxLength: 3, default: USD } + receivingAddress: { type: string, nullable: true, maxLength: 1000 } + contactName: { type: string, maxLength: 128 } + contactPhone: { type: string, maxLength: 64 } + remark: { type: string, nullable: true, maxLength: 1000 } + idempotencyKey: { type: string, maxLength: 255 } + NullableTextPatch: { type: string, nullable: true, description: 缺失不修改;null 或空串清空 } + ProcurementUpdate: + type: object + required: [version] + properties: + version: { type: integer, format: int64, minimum: 0 } + purchaseCategory: { type: string, maxLength: 128 } + productName: { type: string, maxLength: 255 } + productSpecification: { $ref: '#/components/schemas/NullableTextPatch' } + productUrl: { $ref: '#/components/schemas/NullableTextPatch' } + inboundQuantity: { type: integer, format: int64, minimum: 0 } + purchaseWarehouseId: { $ref: '#/components/schemas/NullableTextPatch' } + purchaseWarehouseName: { $ref: '#/components/schemas/NullableTextPatch' } + domesticCarrier: { $ref: '#/components/schemas/NullableTextPatch' } + domesticTrackingNo: { $ref: '#/components/schemas/NullableTextPatch' } + overseasCarrier: { $ref: '#/components/schemas/NullableTextPatch' } + overseasTrackingNo: { $ref: '#/components/schemas/NullableTextPatch' } + estimatedArrivalAt: { type: string, format: date-time, nullable: true, description: 缺失不修改;null 或空串清空 } + remark: { $ref: '#/components/schemas/NullableTextPatch' } + CustomerOptionResponse: + type: object + properties: + code: { type: string, enum: ['200'] } + data: { type: array, items: { type: object, properties: { customerId: { type: string }, customerName: { type: string } } } } + WarehouseOptionResponse: + type: object + properties: + code: { type: string, enum: ['200'] } + data: { type: array, items: { type: object, properties: { warehouseId: { type: string }, warehouseCode: { type: string }, warehouseName: { type: string }, countryCode: { type: string } } } } + TruckStatus: { type: string, enum: [PENDING_REVIEW, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED] } + TruckCreate: + type: object + required: [origin, destination, palletCount, totalWeightKg, totalVolumeCbm, contactName, contactInfo, requestedDate, idempotencyKey] + properties: + transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] } + origin: { type: string, maxLength: 255 } + originAddress: { type: string, nullable: true, maxLength: 500 } + destination: { type: string, maxLength: 255 } + destinationAddress: { type: string, nullable: true, maxLength: 500 } + containerType: { type: string, nullable: true, maxLength: 32 } + palletCount: { type: integer, minimum: 1 } + packageCount: { type: integer, nullable: true, minimum: 0 } + totalWeightKg: { type: number, minimum: 0 } + totalVolumeCbm: { type: number, minimum: 0 } + budgetAmount: { type: number, nullable: true, minimum: 0 } + currency: { type: string, minLength: 3, maxLength: 3, default: USD } + contactName: { type: string, maxLength: 128 } + contactInfo: { type: string, maxLength: 128 } + requestedDate: { type: string, format: date } + remark: { type: string, nullable: true, maxLength: 1000 } + idempotencyKey: { type: string, maxLength: 255 } + TruckUpdate: + type: object + required: [version] + properties: + version: { type: integer, format: int64, minimum: 0 } + transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] } + origin: { type: string, maxLength: 255 } + originAddress: { type: string, nullable: true, maxLength: 500 } + destination: { type: string, maxLength: 255 } + destinationAddress: { type: string, nullable: true, maxLength: 500 } + containerType: { type: string, nullable: true, maxLength: 32 } + palletCount: { type: integer, minimum: 1 } + packageCount: { type: integer, minimum: 0 } + totalWeightKg: { type: number, minimum: 0 } + totalVolumeCbm: { type: number, minimum: 0 } + budgetAmount: { type: number, nullable: true, minimum: 0 } + currency: { type: string, minLength: 3, maxLength: 3 } + contactName: { type: string, maxLength: 128 } + contactInfo: { type: string, maxLength: 128 } + requestedDate: { type: string, format: date } + remark: { type: string, nullable: true, maxLength: 1000 } + LooseboardStatus: { type: string, enum: [PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] } + LooseboardCreate: + type: object + required: [transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey] + properties: + transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } + palletRequired: { type: string, enum: [YES, NO] } + transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] } + origin: { type: string, maxLength: 255 } + originAddress: { type: string, nullable: true, maxLength: 500 } + destination: { type: string, maxLength: 255 } + destinationAddress: { type: string, nullable: true, maxLength: 500 } + containerType: { type: string, nullable: true, maxLength: 32 } + packageCount: { type: integer, minimum: 0 } + totalWeightKg: { type: number, minimum: 0 } + totalVolumeCbm: { type: number, minimum: 0 } + budgetAmount: { type: number, nullable: true, minimum: 0 } + currency: { type: string, minLength: 3, maxLength: 3, default: USD } + contactName: { type: string, nullable: true, maxLength: 128 } + contactInfo: { type: string, nullable: true, maxLength: 128 } + requestedDate: { type: string, format: date, nullable: true } + remark: { type: string, nullable: true, maxLength: 1000 } + idempotencyKey: { type: string, maxLength: 255 } + LooseboardUpdate: + type: object + required: [version, transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm] + properties: + version: { type: integer, format: int64, minimum: 0 } + transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } + palletRequired: { type: string, enum: [YES, NO] } + transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] } + origin: { type: string, maxLength: 255 } + originAddress: { type: string, nullable: true, maxLength: 500 } + destination: { type: string, maxLength: 255 } + destinationAddress: { type: string, nullable: true, maxLength: 500 } + containerType: { type: string, nullable: true, maxLength: 32 } + packageCount: { type: integer, minimum: 0 } + totalWeightKg: { type: number, minimum: 0 } + totalVolumeCbm: { type: number, minimum: 0 } + budgetAmount: { type: number, nullable: true, minimum: 0 } + currency: { type: string, minLength: 3, maxLength: 3, default: USD } + contactName: { type: string, nullable: true, maxLength: 128 } + contactInfo: { type: string, nullable: true, maxLength: 128 } + requestedDate: { type: string, format: date, nullable: true } + remark: { type: string, nullable: true, maxLength: 1000 } + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } diff --git a/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml new file mode 100644 index 000000000..f67455ca1 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml @@ -0,0 +1,273 @@ +openapi: 3.0.3 +info: + title: Lastleg Overseas Master Data API + version: 1.0.0 + description: 海外仓报价、月度汇率与车辆主数据;车辆契约不包含派送履约历史。 +paths: + /api/platform/v1/last-leg/master-data/overseas-quotes: + get: + summary: 分页查询海外仓报价 + description: '权限:platform:overseas-master-data:quote:list' + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Size' + - { name: keyword, in: query, schema: { type: string } } + - { name: warehouseId, in: query, schema: { type: string } } + - { name: providerName, in: query, schema: { type: string } } + - { name: currency, in: query, schema: { type: string } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/ActiveStatus' } } + responses: { '200': { description: 业务响应,data为PageResult } } + post: + summary: 创建海外仓报价 + description: '权限:platform:overseas-master-data:quote:create;服务端读取国内overseas-partners并缓存合作仓目录30分钟' + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/QuoteCreate' } } } } + responses: { '200': { description: 业务响应,data为报价详情 } } + /api/platform/v1/last-leg/master-data/overseas-quotes/warehouse-options: + get: + summary: 查询缓存的有效海外合作仓选项 + description: '权限:platform:overseas-master-data:quote:list;浏览器不直接调用国内内部接口' + responses: { '200': { description: 业务响应 } } + /api/platform/v1/last-leg/master-data/overseas-quotes/export: + get: + summary: 导出海外仓报价 + description: '权限:platform:overseas-master-data:quote:export;UTF-8 BOM CSV' + parameters: + - { name: keyword, in: query, schema: { type: string } } + - { name: warehouseId, in: query, schema: { type: string } } + - { name: providerName, in: query, schema: { type: string } } + - { name: currency, in: query, schema: { type: string } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/ActiveStatus' } } + responses: { '200': { description: CSV, content: { text/csv: { schema: { type: string, format: binary } } } } } + /api/platform/v1/last-leg/master-data/overseas-quotes/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + get: + summary: 查询海外仓报价详情 + description: '权限:platform:overseas-master-data:quote:list' + responses: { '200': { description: 业务响应 } } + put: + summary: 编辑未生效的海外仓报价 + description: '权限:platform:overseas-master-data:quote:edit;recordVersion乐观锁' + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/QuoteUpdate' } } } } + responses: { '200': { description: 业务响应 } } + /api/platform/v1/last-leg/master-data/overseas-quotes/{id}/status: + patch: + summary: 启停海外仓报价 + description: '权限:platform:overseas-master-data:quote:status' + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/QuoteStatusChange' } } } } + responses: { '200': { description: 业务响应 } } + /api/platform/v1/last-leg/master-data/exchange-rates: + get: + summary: 分页查询月度汇率 + description: '权限:platform:overseas-master-data:exchange-rate:list' + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Size' + - { name: monthFrom, in: query, schema: { type: string, pattern: '^\d{4}-(0[1-9]|1[0-2])$' } } + - { name: monthTo, in: query, schema: { type: string, pattern: '^\d{4}-(0[1-9]|1[0-2])$' } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/ActiveStatus' } } + responses: { '200': { description: 业务响应,data为PageResult } } + post: + summary: 创建月度汇率 + description: '权限:platform:overseas-master-data:exchange-rate:create;月份唯一,初始状态ACTIVE' + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExchangeRateCreate' } } } } + responses: { '200': { description: 业务响应,data为MonthlyExchangeRate } } + /api/platform/v1/last-leg/master-data/exchange-rates/summary: + get: + summary: 查询最新有效月度汇率摘要 + description: '权限:platform:overseas-master-data:exchange-rate:list' + responses: { '200': { description: 业务响应,data含latestMonth/latestUsdToCnyAverage/updatedBy/updatedAt } } + /api/platform/v1/last-leg/master-data/exchange-rates/export: + get: + summary: 导出筛选后的全部月度汇率 + description: '权限:platform:overseas-master-data:exchange-rate:export;UTF-8 BOM CSV' + parameters: + - { name: monthFrom, in: query, schema: { type: string } } + - { name: monthTo, in: query, schema: { type: string } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/ActiveStatus' } } + responses: { '200': { description: CSV, content: { text/csv: { schema: { type: string, format: binary } } } } } + /api/platform/v1/last-leg/master-data/exchange-rates/{month}: + parameters: + - { name: month, in: path, required: true, schema: { type: string, pattern: '^\d{4}-(0[1-9]|1[0-2])$' } } + get: + summary: 查询月度汇率详情 + description: '权限:platform:overseas-master-data:exchange-rate:list' + responses: { '200': { description: 业务响应,data为MonthlyExchangeRate } } + put: + summary: 编辑月度汇率 + description: '权限:platform:overseas-master-data:exchange-rate:edit;recordVersion冲突返回业务错误' + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExchangeRateUpdate' } } } } + responses: { '200': { description: 业务响应 } } + /api/platform/v1/last-leg/master-data/exchange-rates/{month}/status: + patch: + summary: 变更月度汇率状态 + description: '权限:platform:overseas-master-data:exchange-rate:status' + parameters: + - { name: month, in: path, required: true, schema: { type: string } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExchangeRateStatus' } } } } + responses: { '200': { description: 业务响应 } } + /api/platform/v1/last-leg/master-data/vehicles: + get: + summary: 分页查询车辆主数据 + description: '权限:platform:overseas-master-data:vehicle:list' + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Size' + - { name: keyword, in: query, description: 车牌/车型/司机或外派单位, schema: { type: string } } + - { name: ownership, in: query, schema: { $ref: '#/components/schemas/VehicleOwnership' } } + - { name: region, in: query, schema: { $ref: '#/components/schemas/VehicleRegion' } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/VehicleStatus' } } + responses: { '200': { description: 业务响应,data为PageResult } } + post: + summary: 创建车辆主数据 + description: '权限:platform:overseas-master-data:vehicle:create;车牌号唯一' + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/VehicleCreate' } } } } + responses: { '200': { description: 业务响应,data为Vehicle } } + /api/platform/v1/last-leg/master-data/vehicles/export: + get: + summary: 导出筛选后的全部车辆主数据 + description: '权限:platform:overseas-master-data:vehicle:export;UTF-8 BOM CSV' + parameters: + - { name: keyword, in: query, schema: { type: string } } + - { name: ownership, in: query, schema: { $ref: '#/components/schemas/VehicleOwnership' } } + - { name: region, in: query, schema: { $ref: '#/components/schemas/VehicleRegion' } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/VehicleStatus' } } + responses: { '200': { description: CSV, content: { text/csv: { schema: { type: string, format: binary } } } } } + /api/platform/v1/last-leg/master-data/vehicles/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + get: + summary: 查询车辆档案详情 + description: '权限:platform:overseas-master-data:vehicle:list;不包含派送履约或车辆更换历史' + responses: { '200': { description: 业务响应,data为Vehicle } } + put: + summary: 编辑车辆主数据 + description: '权限:platform:overseas-master-data:vehicle:edit;状态由独立接口变更' + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/VehicleUpdate' } } } } + responses: { '200': { description: 业务响应 } } + /api/platform/v1/last-leg/master-data/vehicles/{id}/status: + patch: + summary: 变更车辆状态 + description: '权限:platform:overseas-master-data:vehicle:status;使用recordVersion乐观锁' + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/VehicleStatusChange' } } } } + responses: { '200': { description: 业务响应 } } +components: + parameters: + Page: { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + Size: { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } + schemas: + ActiveStatus: { type: string, enum: [ACTIVE, INACTIVE] } + QuoteFee: + type: object + required: [item, billingUnit, unitPrice] + properties: + item: { type: string, maxLength: 128 } + billingUnit: { type: string, maxLength: 64 } + unitPrice: { type: number, minimum: 0 } + minimumCharge: { type: number, minimum: 0, nullable: true } + remark: { type: string, maxLength: 500, nullable: true } + QuoteTransitPrice: + type: object + required: [fbaWarehouseCode, deliveryMethod, price] + properties: + fbaWarehouseCode: { type: string, maxLength: 64 } + deliveryMethod: { type: string, maxLength: 64 } + estimatedMiles: { type: number, minimum: 0, nullable: true } + price: { type: number, minimum: 0 } + minimumCharge: { type: number, minimum: 0, nullable: true } + remark: { type: string, maxLength: 500, nullable: true } + QuoteCreate: + type: object + required: [name, warehouseId, providerName, currency, effectiveStart, effectiveEnd, versionCode, warehouseFees] + properties: + name: { type: string, maxLength: 128 } + warehouseId: { type: string, description: 国内平台海外合作仓ID } + providerName: { type: string, maxLength: 128 } + currency: { type: string, minLength: 3, maxLength: 3 } + effectiveStart: { type: string, format: date } + effectiveEnd: { type: string, format: date } + versionCode: { type: string, maxLength: 64 } + warehouseFees: { type: array, minItems: 1, items: { $ref: '#/components/schemas/QuoteFee' } } + transitPrices: { type: array, items: { $ref: '#/components/schemas/QuoteTransitPrice' } } + remark: { type: string, maxLength: 1000, nullable: true } + QuoteUpdate: + type: object + required: [recordVersion, name, providerName, currency, effectiveStart, effectiveEnd, warehouseFees] + properties: + recordVersion: { type: integer, format: int64, minimum: 0 } + name: { type: string, maxLength: 128 } + providerName: { type: string, maxLength: 128 } + currency: { type: string, minLength: 3, maxLength: 3 } + effectiveStart: { type: string, format: date } + effectiveEnd: { type: string, format: date } + warehouseFees: { type: array, minItems: 1, items: { $ref: '#/components/schemas/QuoteFee' } } + transitPrices: { type: array, items: { $ref: '#/components/schemas/QuoteTransitPrice' } } + remark: { type: string, maxLength: 1000, nullable: true } + QuoteStatusChange: + type: object + required: [status, recordVersion] + properties: + status: { $ref: '#/components/schemas/ActiveStatus' } + recordVersion: { type: integer, format: int64, minimum: 0 } + ExchangeRateCreate: + type: object + required: [month, usdToCnyAverage] + properties: + month: { type: string, example: '2026-08' } + usdToCnyAverage: { type: number, exclusiveMinimum: true, minimum: 0, example: 7.18540000 } + remark: { type: string, maxLength: 1000, nullable: true } + ExchangeRateUpdate: + type: object + required: [recordVersion, usdToCnyAverage] + properties: + recordVersion: { type: integer, format: int64, minimum: 0 } + usdToCnyAverage: { type: number, exclusiveMinimum: true, minimum: 0 } + remark: { type: string, maxLength: 1000, nullable: true } + ExchangeRateStatus: + type: object + required: [status, recordVersion] + properties: + status: { $ref: '#/components/schemas/ActiveStatus' } + recordVersion: { type: integer, format: int64, minimum: 0 } + remark: { type: string, maxLength: 500, nullable: true } + VehicleOwnership: { type: string, enum: [OWNED, DISPATCHED] } + VehicleRegion: { type: string, enum: [DOMESTIC, OVERSEAS] } + VehicleStatus: { type: string, enum: [IN_SERVICE, DISPATCHED, MAINTENANCE, INACTIVE, RETURNED] } + VehicleCreate: + type: object + required: [plateNumber, model, fuelConsumptionPer100Km, mileageKm, region, ownership, status] + properties: + plateNumber: { type: string, maxLength: 64 } + model: { type: string, maxLength: 128 } + fuelConsumptionPer100Km: { type: number, exclusiveMinimum: true, minimum: 0 } + mileageKm: { type: integer, format: int64, minimum: 0 } + driverOrDispatchCompany: { type: string, maxLength: 128, nullable: true } + phone: { type: string, maxLength: 64, nullable: true } + region: { $ref: '#/components/schemas/VehicleRegion' } + ownership: { $ref: '#/components/schemas/VehicleOwnership' } + status: { $ref: '#/components/schemas/VehicleStatus' } + remark: { type: string, maxLength: 1000, nullable: true } + VehicleUpdate: + type: object + required: [recordVersion, plateNumber, model, fuelConsumptionPer100Km, mileageKm, region, ownership] + properties: + recordVersion: { type: integer, format: int64, minimum: 0 } + plateNumber: { type: string, maxLength: 64 } + model: { type: string, maxLength: 128 } + fuelConsumptionPer100Km: { type: number, exclusiveMinimum: true, minimum: 0 } + mileageKm: { type: integer, format: int64, minimum: 0, description: 不得小于已有公里码 } + driverOrDispatchCompany: { type: string, maxLength: 128, nullable: true } + phone: { type: string, maxLength: 64, nullable: true } + region: { $ref: '#/components/schemas/VehicleRegion' } + ownership: { $ref: '#/components/schemas/VehicleOwnership' } + remark: { type: string, maxLength: 1000, nullable: true } + VehicleStatusChange: + type: object + required: [status, recordVersion] + properties: + status: { $ref: '#/components/schemas/VehicleStatus' } + recordVersion: { type: integer, format: int64, minimum: 0 } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java index a00c65ad8..37475f63c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java @@ -1,7 +1,10 @@ package com.baiyue.lastleg.controller; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.security.InternalServiceTokenGuard; import com.baiyue.lastleg.service.LastlegOrderService; import com.baiyue.lastleg.service.LoadingCompletedConsumer; @@ -12,6 +15,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -24,7 +28,9 @@ class LastlegOrderControllerTest { @Test void customerCannotOverrideItsTenantScope() { - controller.list(user(100L, "MERCHANT"), "200", query); + ApiResponse> response = + controller.list(user(100L, "MERCHANT"), "200", query); + assertNotNull(response); verify(service).page(eq("100"), eq(query)); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java new file mode 100644 index 000000000..656f50da5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java @@ -0,0 +1,61 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.lastleg.delivery.controller.DeliveryTransferController; +import com.baiyue.lastleg.dispatch.controller.DispatchController; +import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController; +import com.baiyue.lastleg.inboundplan.pda.controller.InboundPalletAssignmentController; +import com.baiyue.lastleg.inboundplan.pda.controller.InboundPdaController; +import com.baiyue.lastleg.inventory.command.controller.InventoryCommandController; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class Phase4BrowserApiBoundaryTest { + @Test + void inboundPdaAndUnloadPhotoApisUseThePdaAccessDomain() { + assertTrue(basePath(InboundPdaController.class).startsWith("/api/pda/v1/")); + assertTrue(basePath(InboundPalletAssignmentController.class).startsWith("/api/pda/v1/")); + assertFalse(basePath(InboundPdaController.class).contains("/api/platform/")); + } + + @Test + void excludedOutboundControllersAreDisabledUnlessExplicitlyEnabledLater() { + for (Class type : new Class[] { + DispatchController.class, DeliveryTransferController.class, DispatchPdaController.class + }) { + ConditionalOnProperty condition = type.getAnnotation(ConditionalOnProperty.class); + assertTrue(condition != null, type.getSimpleName()); + assertTrue(Arrays.asList(condition.name()).contains("lastleg.future-outbound-api.enabled")); + assertFalse(condition.matchIfMissing()); + } + } + + @Test + void platformInventoryCommandsDoNotMapOutboundAllocationPickingOrShipping() { + Set paths = Arrays.stream(InventoryCommandController.class.getDeclaredMethods()) + .flatMap(method -> Arrays.stream(method.getAnnotations())) + .filter(annotation -> annotation.annotationType().getName().startsWith("org.springframework.web.bind.annotation")) + .flatMap(annotation -> { + try { + return Arrays.stream((String[]) annotation.annotationType().getMethod("value").invoke(annotation)); + } catch (ReflectiveOperationException ignored) { + return java.util.stream.Stream.empty(); + } + }).collect(Collectors.toSet()); + assertFalse(paths.contains("/allocate")); + assertFalse(paths.contains("/unallocate")); + assertFalse(paths.contains("/pick")); + assertFalse(paths.contains("/ship")); + } + + private static String basePath(Class type) { + return type.getAnnotation(RequestMapping.class).value()[0]; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index d02e94c48..29cbef8a2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -1,7 +1,6 @@ package com.baiyue.lastleg.delivery.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.baiyue.common.core.api.PageResult; import com.baiyue.lastleg.delivery.dto.DeliveryRequests; import com.baiyue.lastleg.delivery.dto.DeliveryResponses; import com.baiyue.lastleg.delivery.entity.DeliveryOrder; @@ -204,32 +203,20 @@ class DeliveryTransferServiceTest { } @Test - void outboundDetailReturnsPagedPalletDestinationPackagesAndInboundCode() { + void outboundDetailDoesNotExposeExcludedPalletOrOutboundFulfillmentRows() { OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); - OutboundPalletItem first = pallet(501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12); - OutboundPalletItem second = pallet(502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8); when(outboundOrders.findByNo("TENANT-A", "OUT-301")).thenReturn(outbound); when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); - when(outboundPallets.selectPage(any(Page.class), any())).thenAnswer(invocation -> { - @SuppressWarnings("unchecked") Page page = invocation.getArgument(0); - page.setRecords(List.of(first, second)); - page.setTotal(2); - return page; - }); DeliveryResponses.OutboundDetail detail = service.outboundDetail("TENANT-A", "OUT-301", new DeliveryRequests.PalletQuery(1, 20, null)); - PageResult pallets = detail.pallets(); - assertEquals(2, pallets.total()); - assertEquals(2, pallets.records().size()); - assertEquals("ONT8", pallets.records().get(0).destinationWarehouseCode()); - assertEquals("Ontario", pallets.records().get(0).destinationWarehouseName()); - assertEquals(12, pallets.records().get(0).packageCount()); - assertEquals("IN-A", pallets.records().get(0).inboundCode()); - assertEquals("LAX9", pallets.records().get(1).destinationWarehouseCode()); - assertEquals(8, pallets.records().get(1).packageCount()); + assertEquals("OUT-301", detail.outbound().outboundNo()); + assertEquals("DEL-401", detail.delivery().deliveryNo()); + assertTrue(java.util.Arrays.stream(DeliveryResponses.OutboundDetail.class.getRecordComponents()) + .noneMatch(component -> "pallets".equals(component.getName()))); + verify(outboundPallets, never()).selectPage(any(Page.class), any()); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java index 32b31ef38..dcc6bac23 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java @@ -10,11 +10,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class InboundPlanSchemaContractTest { @Test - void planIsUniqueByTenantAndActualContainerNumber() throws IOException { + void planIsUniqueByCustomerAndSourceOrderWhileContainerCanBeShared() throws IOException { try (var stream = getClass().getClassLoader().getResourceAsStream("db/lastleg.sql")) { assertTrue(stream != null, "lastleg schema must be available"); String schema = new String(stream.readAllBytes(), StandardCharsets.UTF_8).toLowerCase(); - assertTrue(schema.contains("unique key uk_ll_inbound_plan_container (customer_id, container_no)")); + assertTrue(schema.contains("unique key uk_ll_inbound_plan_source_order (customer_id, source_order_id)")); + assertTrue(schema.contains("key idx_ll_inbound_plan_container (customer_id, container_no)")); + assertFalse(schema.contains("unique key uk_ll_inbound_plan_container")); assertTrue(schema.contains("default 'pending_assignment'")); assertTrue(schema.contains("required_package_count")); assertTrue(schema.contains("planned_package_count")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java new file mode 100644 index 000000000..8d3f3b085 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.inboundplan.controller; + +import io.swagger.v3.oas.annotations.Operation; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class InboundPlanPermissionContractTest { + private static final String LIST = "platform:overseas-warehouse:inbound-order:list"; + + @Test + void listAndDetailUseOnlyCanonicalInboundOrderListAuthority() { + Map methods = Arrays.stream(InboundPlanController.class.getDeclaredMethods()) + .collect(Collectors.toMap(Method::getName, method -> method)); + + for (String name : new String[] {"page", "detail", "fbaAppointmentDetail"}) { + Method method = methods.get(name); + assertEquals("hasAuthority('" + LIST + "')", method.getAnnotation(PreAuthorize.class).value()); + assertEquals("Required authority: " + LIST, method.getAnnotation(Operation.class).description()); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index 5ab60faa6..1199e5409 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -13,6 +13,7 @@ import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.dropship.service.DropshipOrderService; @@ -42,6 +43,7 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class InboundStockReceiptServiceTest { @Mock LastlegOrderMapper orders; + @Mock LastlegOrderDetailMapper orderDetails; @Mock InboundStockReceiptMapper receipts; @Mock InventoryCommandService inventory; @Mock DropshipOrderService dropshipOrders; @@ -52,6 +54,7 @@ class InboundStockReceiptServiceTest { void setUp() { service = new InboundStockReceiptService(); ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "orderDetails", orderDetails); ReflectionTestUtils.setField(service, "receipts", receipts); ReflectionTestUtils.setField(service, "inventory", inventory); ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders); @@ -74,6 +77,7 @@ class InboundStockReceiptServiceTest { assertEquals(5L, request.getValue().packages()); assertEquals(101L, request.getValue().inboundOrderId()); assertEquals("IN-1", request.getValue().inboundCode()); + assertEquals("SKU-1", request.getValue().skuCode()); ArgumentCaptor receipt = ArgumentCaptor.forClass(InboundStockReceipt.class); verify(receipts).insert(receipt.capture()); assertEquals(5L, receipt.getValue().getReceivedPackages()); @@ -84,10 +88,11 @@ class InboundStockReceiptServiceTest { } @Test - void storesOneInboundOrderAcrossMultiplePalletsWithoutSkuSplitting() { + void splitsOneInboundOrderAcrossSkuAndPalletDimensions() { Fixture fixture = fixture(10, 5, 5, 10); fixture.detail.setPackageCount(5); LastlegOrderDetail second = detail(202L, "SKU-2", 5, 7); + when(orderDetails.selectList(any())).thenReturn(List.of(fixture.detail, second)); InboundPlanPallet secondPallet = pallet(22L, 2002L); InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5); when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); @@ -187,6 +192,7 @@ class InboundStockReceiptServiceTest { order.setCurrentStatus("INBOUND_PLANNED"); org.mockito.Mockito.lenient().when(orders.lockById("100", 101L)).thenReturn(order); LastlegOrderDetail detail = detail(201L, "SKU-1", required, quantity); + org.mockito.Mockito.lenient().when(orderDetails.selectList(any())).thenReturn(List.of(detail)); return new Fixture(plan, task, line, order, detail, allocation(31L, 12L, 21L, picked), pallet(21L, 2001L)); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java new file mode 100644 index 000000000..0e4946ebb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java @@ -0,0 +1,98 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.OverseasContainerUnloadedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.lastleg.client.EventBridgeClient; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.repository.UnloadCompletedOutboxRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class InboundUnloadCompletedOutboxRelayTest { + @Mock private UnloadCompletedOutboxRepository repository; + @Mock private EventBridgeClient client; + private ObjectMapper objectMapper; + private InboundUnloadCompletedOutboxRelay relay; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + relay = new InboundUnloadCompletedOutboxRelay(); + ReflectionTestUtils.setField(relay, "repository", repository); + ReflectionTestUtils.setField(relay, "client", client); + ReflectionTestUtils.setField(relay, "objectMapper", objectMapper); + ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); + ReflectionTestUtils.setField(relay, "leaseSeconds", 60L); + ReflectionTestUtils.setField(relay, "retrySeconds", 30L); + } + + @Test + void marksOutboxPublishedAfterDomesticBridgeAcknowledgesSameEvent() throws Exception { + EventEnvelope event = event(); + LastlegEventOutbox row = row(event); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))) + .thenReturn(ApiResponse.success(new EventBridgeClient.Acknowledgement( + event.eventId(), "已接收", "2026-08-20T03:01:00"))); + + relay.relay(); + + verify(repository).published(eq(9001L), anyString(), any(LocalDateTime.class)); + verify(repository, never()).retry(anyLong(), anyString(), any(), anyString()); + } + + @Test + void releasesLeaseForRetryWhenBridgeDeliveryFails() throws Exception { + EventEnvelope event = event(); + LastlegEventOutbox row = row(event); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))) + .thenThrow(new IllegalStateException("bridge unavailable")); + + relay.relay(); + + verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq("bridge unavailable")); + verify(repository, never()).published(anyLong(), anyString(), any()); + } + + private LastlegEventOutbox row(EventEnvelope event) throws Exception { + LastlegEventOutbox row = new LastlegEventOutbox(); + row.setId(9001L); + row.setEventKey("OverseasContainerUnloaded:7001"); + row.setPayloadJson(objectMapper.writeValueAsString(event)); + return row; + } + + private EventEnvelope event() { + var payload = objectMapper.valueToTree(new OverseasContainerUnloadedV1("1001", "MSCU1234567", + Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null)); + return new EventEnvelope("00000000-0000-4000-8000-000000000009", + EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, Instant.parse("2026-08-20T03:00:01Z"), + RegionCode.OVERSEAS, RegionCode.CN, "1", "CONTAINER_BOOKING", "1001", 7L, 1L, + "trace-12345678", "correlation-12345678", null, payload, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index a534f549d..30261d8ff 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -1,6 +1,8 @@ package com.baiyue.lastleg.inboundplan.service; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanSummary; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; @@ -22,6 +24,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.util.List; +import java.time.LocalDateTime; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -115,6 +118,35 @@ class InboundPlanServiceTest { verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()); } + @Test + void pagesInboundPlansWithinCustomerScope() { + InboundPlanRequests.Query query = new InboundPlanRequests.Query( + 2, 20, "LIP", "RK", "PENDING_ASSIGNMENT", "WH-1", "ONT8", "ONT8", + "NOT_APPOINTED", null, null); + InboundPlanSummary row = new InboundPlanSummary(1L, "LIP1", 2L, "100", "Customer A", + "FBA", "RK-1", "CONT-1", "WH-1", "LA Warehouse", "ONT8", "ONT8", "ONT8", + "ONT8", 10, BigDecimal.ONE, 1, "PENDING_ASSIGNMENT", "NOT_APPOINTED", null, null); + when(planMapper.countRows("100", query)).thenReturn(21L); + when(planMapper.pageRows("100", query, 20L, 20L)).thenReturn(List.of(row)); + + var result = service.page("100", query); + + assertEquals(21L, result.total()); + assertEquals(2L, result.pages()); + assertEquals("RK-1", result.records().get(0).inboundCode()); + verify(planMapper).pageRows("100", query, 20L, 20L); + } + + @Test + void rejectsReversedInboundPlanDateRange() { + InboundPlanRequests.Query query = new InboundPlanRequests.Query(1, 20, null, null, null, null, + null, null, null, LocalDateTime.of(2026, 8, 20, 0, 0), + LocalDateTime.of(2026, 8, 19, 0, 0)); + + assertThrows(ResponseStatusException.class, () -> service.page("100", query)); + verify(planMapper, never()).countRows(any(), any()); + } + @Test void rejectsMissingDestinationWarehouse() { SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1", diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java index a5f96dab4..d472d0a53 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java @@ -28,12 +28,13 @@ class InventorySchemaContractTest { } assertTrue(schema.contains("create table if not exists ll_inventory_balance")); - assertTrue(schema.contains("unique key uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)")); + assertTrue(schema.contains("unique key uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code,sku_code)")); assertTrue(schema.contains("inbound_order_id bigint not null")); assertTrue(schema.contains("inbound_code varchar(128) not null")); assertTrue(schema.contains("available_packages bigint not null default 0")); assertTrue(schema.contains("in_transit_packages bigint not null default 0")); - assertFalse(tableDefinition(schema, "ll_inventory_balance").contains("sku_code")); + assertTrue(tableDefinition(schema, "ll_inventory_balance").contains("sku_code")); + assertTrue(tableDefinition(schema, "ll_inventory_balance").contains("product_name_zh")); assertFalse(tableDefinition(schema, "ll_inventory_balance").contains("_quantity")); assertTrue(schema.contains("create table if not exists ll_inventory_position")); assertTrue(schema.contains("unique key uk_ll_inventory_position (balance_id,pallet_id,batch_no)")); @@ -52,7 +53,7 @@ class InventorySchemaContractTest { assertTrue(schema.contains("unique key uk_ll_inventory_ledger_idempotency (customer_id,idempotency_key)")); assertTrue(schema.contains("create table if not exists ll_inventory_operation")); assertTrue(schema.contains("create table if not exists ll_inbound_stock_receipt")); - assertTrue(schema.contains("unique key uk_ll_stock_receipt_task_allocation (customer_id,task_id,allocation_id)")); + assertTrue(schema.contains("unique key uk_ll_stock_receipt_task_allocation_sku (customer_id,task_id,allocation_id,sku_code)")); assertFalse(tableDefinition(schema, "ll_inventory_ledger").contains("updated_at")); } @@ -97,7 +98,8 @@ class InventorySchemaContractTest { assertNotNull(InventoryBalance.class.getDeclaredField("inboundOrderId")); assertNotNull(InventoryBalance.class.getDeclaredField("inboundCode")); assertNotNull(InventoryLedger.class.getDeclaredField("inboundOrderId")); - assertThrowsNoField(InventoryBalance.class, "skuCode"); + assertNotNull(InventoryBalance.class.getDeclaredField("skuCode")); + assertNotNull(InventoryBalance.class.getDeclaredField("productNameZh")); assertThrowsNoField(InventoryBalance.class, "availableQuantity"); assertThrowsNoField(InventoryPosition.class, "skuCode"); assertThrowsNoField(InventoryPosition.class, "productQuantity"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java index b85001fd9..c684f5c47 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -63,7 +63,7 @@ class InventoryCommandServiceTest { void stockInUpdatesBalancePositionAndLedgerInOneTransaction() throws Exception { assertTrue(InventoryCommandService.class.getMethod("stockIn", String.class, String.class, InventoryCommandRequests.StockIn.class).getAnnotation(Transactional.class) != null); - when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null); + when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1", "SKU-1")).thenReturn(null); var result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-1", 12, 24)); assertEquals(12, result.availablePackages()); @@ -71,6 +71,7 @@ class InventoryCommandServiceTest { ArgumentCaptor balance = ArgumentCaptor.forClass(InventoryBalance.class); verify(balances).updateById(balance.capture()); assertEquals(12L, balance.getValue().getAvailablePackages()); + assertEquals("SKU-1", balance.getValue().getSkuCode()); verify(positions).insert(any(InventoryPosition.class)); verify(ledgers).insert(any(InventoryLedger.class)); verify(operations).updateById(any(InventoryOperation.class)); @@ -78,7 +79,7 @@ class InventoryCommandServiceTest { @Test void stockInAllowsPalletPositionWithZeroOptionalPackages() { - when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null); + when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1", "SKU-1")).thenReturn(null); var result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-zero", 0, 0)); @@ -104,7 +105,7 @@ class InventoryCommandServiceTest { @Test void replayUsesPersistedResultWithoutMutatingInventoryAgain() throws Exception { var request = stockIn("IN-1", "stock-in-1", 12, 24); - when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null); + when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1", "SKU-1")).thenReturn(null); service.stockIn("100", "7", request); ArgumentCaptor operation = ArgumentCaptor.forClass(InventoryOperation.class); verify(operations).insert(operation.capture()); @@ -138,7 +139,8 @@ class InventoryCommandServiceTest { private InventoryCommandRequests.StockIn stockIn(String inbound, String key, long packages, long quantity) { return new InventoryCommandRequests.StockIn("100", "Customer 100", "WH-1", "WH1", "Warehouse 1", - "DEST-1", "ONT8", "ONT8", 101L, inbound, "IN-CODE-1", 300L, "PLT-1", null, null, null, + "DEST-1", "ONT8", "ONT8", 101L, inbound, "IN-CODE-1", "SKU-1", "Product 1", null, + 300L, "PLT-1", null, null, null, packages, "ORD-1", "EVT-1", key, null); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java index 451032574..d469556ab 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java @@ -18,7 +18,7 @@ class InventoryQueryControllerTest { private final InventoryQueryService service = mock(InventoryQueryService.class); private final InventoryQueryController controller = controller(); private final InventoryQueryRequests.BalanceQuery query = new InventoryQueryRequests.BalanceQuery( - 1, 20, null, null, null, null, null, null, null, null); + 1, 20, null, null, null, null, null, null, null, null, null, null); @Test void merchantCannotOverrideCustomerScope() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java index 1bbfa8ad6..d164e0cc2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java @@ -120,12 +120,11 @@ class InventoryQueryServiceTest { String csv = new String(service.exportBalances("100", query), StandardCharsets.UTF_8); - assertTrue(csv.contains("inboundOrderNo,inboundCode,totalPackages")); - assertFalse(csv.contains("skuCode")); + assertTrue(csv.contains("inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages")); assertFalse(csv.contains("Quantity")); } private static InventoryQueryRequests.BalanceQuery balanceQuery(String status) { - return new InventoryQueryRequests.BalanceQuery(1, 20, null, null, null, null, null, null, null, status); + return new InventoryQueryRequests.BalanceQuery(1, 20, null, null, null, null, null, null, null, null, null, status); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java new file mode 100644 index 000000000..341cbe27c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.looseboard; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LocalLooseboardSchemaContractTest { + @Test + void baselineContainsIndependentLooseboardOrderAndHistory() throws Exception { + String sql; + try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) { + sql = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_local_looseboard_order")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_local_looseboard_history")); + assertTrue(sql.contains("operation_type VARCHAR(32) NOT NULL")); + assertTrue(sql.contains("uk_ll_local_looseboard_no(customer_id,order_no)")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardControllerTest.java new file mode 100644 index 000000000..ccd1af921 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardControllerTest.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.looseboard.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests; +import com.baiyue.lastleg.looseboard.service.LocalLooseboardService; +import com.baiyue.lastleg.security.OverseasPermissions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class PlatformLocalLooseboardControllerTest { + @Mock LocalLooseboardService service; + private PlatformLocalLooseboardController controller; + + @BeforeEach + void setUp() { + controller = new PlatformLocalLooseboardController(); + ReflectionTestUtils.setField(controller, "service", service); + } + + @Test + void createUsesExplicitCustomerAndOperator() { + var request = create(); + controller.create(user(Set.of(OverseasPermissions.LOOSEBOARD_CREATE)), "100", request); + verify(service).create("100", "7", request); + } + + @Test + void updateRequiresIndependentEditPermission() { + var request = new LocalLooseboardRequests.Update(0L, "SHARED_TRUCK", "NO", "PORT_PICKUP", + "LA", null, "Ontario", null, null, 1, BigDecimal.ZERO, BigDecimal.ZERO, + null, "USD", null, null, null, null); + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> controller.update(user(Set.of(OverseasPermissions.LOOSEBOARD_CREATE)), "100", "LB1", request)); + assertEquals(403, error.getStatusCode().value()); + } + + private LocalLooseboardRequests.Create create() { + return new LocalLooseboardRequests.Create("SHARED_TRUCK", "NO", "PORT_PICKUP", "LA", null, + "Ontario", null, null, 1, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", + null, null, LocalDate.now(), null, "key-1"); + } + + private AuthenticatedUser user(Set permissions) { + return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", permissions); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java new file mode 100644 index 000000000..cd60f508c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java @@ -0,0 +1,113 @@ +package com.baiyue.lastleg.looseboard.service; + +import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; +import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper; +import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class LocalLooseboardServiceTest { + @Mock LocalLooseboardOrderMapper orders; + @Mock LocalLooseboardHistoryMapper histories; + private LocalLooseboardService service; + + @BeforeEach + void setUp() { + service = new LocalLooseboardService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "histories", histories); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); + } + + @Test + void createIsTenantScopedIdempotentAndStartsPendingAcceptance() { + AtomicReference saved = new AtomicReference<>(); + when(orders.insert(any(LocalLooseboardOrder.class))).thenAnswer(call -> { + saved.set(call.getArgument(0)); + return 1; + }); + when(orders.findNo(anyString(), anyString())).thenAnswer(call -> saved.get()); + when(histories.listOrder(anyString(), any())).thenReturn(List.of()); + + var detail = service.create("100", "7", create("key-1")); + + assertEquals("100", detail.summary().customerId()); + assertEquals(LocalLooseboardService.PENDING_ACCEPTANCE, detail.summary().status()); + assertEquals("SHARED_TRUCK", detail.summary().transportMode()); + verify(histories).insert(org.mockito.ArgumentMatchers.argThat(value -> + "CREATE".equals(value.getOperationType()))); + } + + @Test + void ordinaryUpdateCannotChangeAcceptedOrder() { + LocalLooseboardOrder order = order(LocalLooseboardService.DISPATCHED, 0L); + when(orders.lockNo("100", "LB1")).thenReturn(order); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.update("100", "LB1", "7", update(0L))); + + assertEquals(409, error.getStatusCode().value()); + } + + @Test + void statusTransitionWritesHistoryAndVersion() { + LocalLooseboardOrder order = order(LocalLooseboardService.PENDING_ACCEPTANCE, 0L); + when(orders.lockNo("100", "LB1")).thenReturn(order); + when(histories.listOrder("100", 1L)).thenReturn(List.of()); + + var detail = service.changeStatus("100", "LB1", "7", + new LocalLooseboardRequests.ChangeStatus(0L, "DISPATCHED", "accepted")); + + assertEquals(LocalLooseboardService.DISPATCHED, detail.summary().status()); + assertEquals(1L, detail.summary().version()); + verify(histories).insert(org.mockito.ArgumentMatchers.argThat(value -> + "STATUS".equals(value.getOperationType()) && "DISPATCHED".equals(value.getToStatus()))); + } + + private LocalLooseboardRequests.Create create(String key) { + return new LocalLooseboardRequests.Create("SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE", + "Los Angeles", "Address A", "Ontario", "Address B", "40HQ", 12, + new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"), + "USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null, key); + } + + private LocalLooseboardRequests.Update update(long version) { + return new LocalLooseboardRequests.Update(version, "SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE", + "Los Angeles", "Address A", "Ontario", "Address B", "40HQ", 12, + new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"), + "USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null); + } + + private LocalLooseboardOrder order(String status, long version) { + LocalLooseboardOrder order = new LocalLooseboardOrder(); + order.setId(1L); order.setCustomerId("100"); order.setOrderNo("LB1"); + order.setTransportMode("SHARED_TRUCK"); order.setPalletRequired("YES"); + order.setTransportType("WAREHOUSE_TO_WAREHOUSE"); order.setOrigin("Los Angeles"); + order.setDestination("Ontario"); order.setPackageCount(12); order.setCurrency("USD"); + order.setStatus(status); order.setVersion(version); order.setCreatedAt(LocalDateTime.now()); + order.setUpdatedAt(order.getCreatedAt()); + return order; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/OverseasMasterDataOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/OverseasMasterDataOpenApiContractTest.java new file mode 100644 index 000000000..b9b072a47 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/OverseasMasterDataOpenApiContractTest.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.masterdata; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasMasterDataOpenApiContractTest { + @Test + void documentsExchangeRateAndVehicleApisWithoutDeliveryFulfillment() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-master-data-v1.yaml")); + assertThat(yaml).contains("/api/platform/v1/last-leg/master-data/overseas-quotes:") + .contains("缓存合作仓目录30分钟") + .contains("/api/platform/v1/last-leg/master-data/exchange-rates:") + .contains("/api/platform/v1/last-leg/master-data/vehicles:") + .contains("platform:overseas-master-data:exchange-rate:export") + .contains("platform:overseas-master-data:vehicle:status") + .contains("不包含派送履约或车辆更换历史") + .doesNotContain("deliveryOrders:") + .doesNotContain("replacementHistory:"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/MonthlyExchangeRateSqlContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/MonthlyExchangeRateSqlContractTest.java new file mode 100644 index 000000000..e1f110696 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/MonthlyExchangeRateSqlContractTest.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.masterdata.exchange; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class MonthlyExchangeRateSqlContractTest { + @Test + void baselineContainsRateAndHistoryTablesWithOptimisticLock() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + assertThat(sql).contains("CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate (") + .contains("UNIQUE KEY uk_ll_exchange_rate_month (rate_month)") + .contains("record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本'") + .contains("CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate_history ("); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateControllerContractTest.java new file mode 100644 index 000000000..cf72432f1 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateControllerContractTest.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.masterdata.exchange.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformMonthlyExchangeRateControllerContractTest { + @Test + void exposesStablePlatformBaseAndCanonicalAuthorities() { + RequestMapping base = PlatformMonthlyExchangeRateController.class.getAnnotation(RequestMapping.class); + assertThat(base.value()).containsExactly("/api/platform/v1/last-leg/master-data/exchange-rates"); + Map authorities = Stream.of(PlatformMonthlyExchangeRateController.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(PreAuthorize.class)) + .collect(Collectors.toMap(method -> method.getName(), method -> method.getAnnotation(PreAuthorize.class).value())); + assertThat(authorities.get("page")).contains("platform:overseas-master-data:exchange-rate:list"); + assertThat(authorities.get("summary")).contains("platform:overseas-master-data:exchange-rate:list"); + assertThat(authorities.get("detail")).contains("platform:overseas-master-data:exchange-rate:list"); + assertThat(authorities.get("create")).contains("platform:overseas-master-data:exchange-rate:create"); + assertThat(authorities.get("update")).contains("platform:overseas-master-data:exchange-rate:edit"); + assertThat(authorities.get("status")).contains("platform:overseas-master-data:exchange-rate:status"); + assertThat(authorities.get("export")).contains("platform:overseas-master-data:exchange-rate:export"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/service/MonthlyExchangeRateServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/service/MonthlyExchangeRateServiceTest.java new file mode 100644 index 000000000..cbb0ba739 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/service/MonthlyExchangeRateServiceTest.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.masterdata.exchange.service; + +import com.baiyue.lastleg.masterdata.exchange.dto.MonthlyExchangeRateDtos; +import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate; +import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateHistoryMapper; +import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class MonthlyExchangeRateServiceTest { + private MonthlyExchangeRateMapper mapper; + private MonthlyExchangeRateService service; + + @BeforeEach + void setUp() { + mapper = mock(MonthlyExchangeRateMapper.class); + service = new MonthlyExchangeRateService(); + ReflectionTestUtils.setField(service, "rates", mapper); + ReflectionTestUtils.setField(service, "histories", mock(MonthlyExchangeRateHistoryMapper.class)); + } + + @Test + void rejectsStaleVersion() { + MonthlyExchangeRate value = new MonthlyExchangeRate(); value.setRecordVersion(4L); value.setRateMonth("2026-08"); + when(mapper.lockMonth("2026-08")).thenReturn(value); + var request = new MonthlyExchangeRateDtos.Update(3L, new BigDecimal("7.18000000"), null); + assertThatThrownBy(() -> service.update("2026-08", "9", request)) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409 CONFLICT"); + } + +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteControllerContractTest.java new file mode 100644 index 000000000..6692c7359 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteControllerContractTest.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.masterdata.quote.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformOverseasQuoteControllerContractTest { + @Test + void exposesStablePlatformBaseAndCanonicalAuthorities() { + RequestMapping base = PlatformOverseasQuoteController.class.getAnnotation(RequestMapping.class); + assertThat(base.value()).containsExactly("/api/platform/v1/last-leg/master-data/overseas-quotes"); + Map authorities = Stream.of(PlatformOverseasQuoteController.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(PreAuthorize.class)) + .collect(Collectors.toMap(method -> method.getName(), + method -> method.getAnnotation(PreAuthorize.class).value())); + assertThat(authorities.get("page")).contains("platform:overseas-master-data:quote:list"); + assertThat(authorities.get("detail")).contains("platform:overseas-master-data:quote:list"); + assertThat(authorities.get("warehouseOptions")).contains("platform:overseas-master-data:quote:list"); + assertThat(authorities.get("create")).contains("platform:overseas-master-data:quote:create"); + assertThat(authorities.get("update")).contains("platform:overseas-master-data:quote:edit"); + assertThat(authorities.get("status")).contains("platform:overseas-master-data:quote:status"); + assertThat(authorities.get("export")).contains("platform:overseas-master-data:quote:export"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectoryTest.java new file mode 100644 index 000000000..5d47f9e32 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectoryTest.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.masterdata.quote.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.masterdata.quote.client.DomesticPartnerWarehouseClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PartnerWarehouseDirectoryTest { + @Mock private DomesticPartnerWarehouseClient client; + private PartnerWarehouseDirectory directory; + + @BeforeEach + void setUp() { + directory = new PartnerWarehouseDirectory(); + ReflectionTestUtils.setField(directory, "client", client); + ReflectionTestUtils.setField(directory, "internalToken", "internal-token"); + ReflectionTestUtils.setField(directory, "ttl", Duration.ofMinutes(30)); + ReflectionTestUtils.setField(directory, "clock", Clock.fixed( + Instant.parse("2026-08-20T00:00:00Z"), ZoneOffset.UTC)); + } + + @Test + void cachesDomesticOptionsForThirtyMinutesAndReturnsIdsAsStrings() { + when(client.activePartners("internal-token")).thenReturn(ApiResponse.success(List.of( + new DomesticPartnerWarehouseClient.Option(Long.valueOf("9007199254740993"), + "US-LAX", "洛杉矶合作仓", "US")))); + + assertThat(directory.options()).singleElement().satisfies(option -> { + assertThat(option.warehouseId()).isEqualTo("9007199254740993"); + assertThat(option.warehouseCode()).isEqualTo("US-LAX"); + assertThat(option.warehouseName()).isEqualTo("洛杉矶合作仓"); + assertThat(option.countryCode()).isEqualTo("US"); + }); + directory.options(); + verify(client, times(1)).activePartners("internal-token"); + + ReflectionTestUtils.setField(directory, "clock", Clock.fixed( + Instant.parse("2026-08-20T00:30:01Z"), ZoneOffset.UTC)); + directory.options(); + verify(client, times(2)).activePartners("internal-token"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/VehicleSqlContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/VehicleSqlContractTest.java new file mode 100644 index 000000000..cde36e0a6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/VehicleSqlContractTest.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.masterdata.vehicle; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class VehicleSqlContractTest { + @Test + void baselineContainsVehicleMasterDataWithoutDeliveryHistory() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + String vehicleSql = sql.substring(sql.indexOf("CREATE TABLE IF NOT EXISTS ll_vehicle (")); + assertThat(vehicleSql).contains("UNIQUE KEY uk_ll_vehicle_plate (plate_number)") + .contains("record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本'") + .contains("ownership VARCHAR(16) NOT NULL") + .doesNotContain("CREATE TABLE IF NOT EXISTS ll_vehicle_delivery") + .doesNotContain("CREATE TABLE IF NOT EXISTS ll_vehicle_replacement"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleControllerContractTest.java new file mode 100644 index 000000000..719dcf8e3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleControllerContractTest.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.masterdata.vehicle.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformVehicleControllerContractTest { + @Test + void exposesStablePlatformBaseAndCanonicalAuthorities() { + RequestMapping base = PlatformVehicleController.class.getAnnotation(RequestMapping.class); + assertThat(base.value()).containsExactly("/api/platform/v1/last-leg/master-data/vehicles"); + Map authorities = Stream.of(PlatformVehicleController.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(PreAuthorize.class)) + .collect(Collectors.toMap(method -> method.getName(), method -> method.getAnnotation(PreAuthorize.class).value())); + assertThat(authorities.get("page")).contains("platform:overseas-master-data:vehicle:list"); + assertThat(authorities.get("detail")).contains("platform:overseas-master-data:vehicle:list"); + assertThat(authorities.get("create")).contains("platform:overseas-master-data:vehicle:create"); + assertThat(authorities.get("update")).contains("platform:overseas-master-data:vehicle:edit"); + assertThat(authorities.get("status")).contains("platform:overseas-master-data:vehicle:status"); + assertThat(authorities.get("export")).contains("platform:overseas-master-data:vehicle:export"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleServiceTest.java new file mode 100644 index 000000000..b83562614 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleServiceTest.java @@ -0,0 +1,63 @@ +package com.baiyue.lastleg.masterdata.vehicle.service; + +import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos; +import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle; +import com.baiyue.lastleg.masterdata.vehicle.mapper.VehicleMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class VehicleServiceTest { + private VehicleMapper mapper; + private VehicleService service; + + @BeforeEach + void setUp() { + mapper = mock(VehicleMapper.class); + service = new VehicleService(); + ReflectionTestUtils.setField(service, "vehicles", mapper); + } + + @Test + void rejectsStaleVersionBeforeUpdating() { + when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L)); + VehicleDtos.Update request = update(2L, 1000L); + assertThatThrownBy(() -> service.update(1L, "9", request)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("409 CONFLICT"); + } + + @Test + void rejectsDecreasingOdometer() { + when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L)); + VehicleDtos.Update request = update(3L, 999L); + assertThatThrownBy(() -> service.update(1L, "9", request)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("400 BAD_REQUEST"); + } + + @Test + void rejectsUnknownStatus() { + when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L)); + assertThatThrownBy(() -> service.changeStatus(1L, "9", new VehicleDtos.ChangeStatus("BUSY", 3L))) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("400 BAD_REQUEST"); + } + + private static VehicleDtos.Update update(long version, long mileage) { + return new VehicleDtos.Update(version, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage, + "Driver", "123", "OVERSEAS", "OWNED", null); + } + + private static Vehicle vehicle(long version, long mileage) { + Vehicle value = new Vehicle(); value.setId(1L); value.setRecordVersion(version); value.setMileageKm(mileage); + value.setStatus("IN_SERVICE"); return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java index 0e2a97a8a..7e5702813 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java @@ -21,5 +21,8 @@ class OverseasProcurementInventoryBoundaryTest { assertFalse(source.contains("stockIn(")); assertFalse(source.contains("setAvailablePackages")); assertFalse(source.contains("availablePackages")); + assertFalse(source.contains("addOutbound")); + assertFalse(source.contains("OverseasProcurementOutbound")); + assertFalse(source.contains("outboundQuantity")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java new file mode 100644 index 000000000..dcd83c472 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.procurement; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OverseasProcurementOpenApiContractTest { + @Test + void procurementContractDocumentsCandidatesClearSemanticsAndExcludesOutbound() throws Exception { + String yaml; + try (var input = getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml")) { + yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + assertTrue(yaml.contains("/customer-options:")); + assertTrue(yaml.contains("/warehouse-options:")); + assertTrue(yaml.contains("显式 null 或空串表示清空")); + assertTrue(yaml.contains("platform:overseas-business:purchase:list")); + assertTrue(yaml.contains("platform:overseas-business:purchase:edit")); + assertFalse(yaml.contains("/outbounds")); + assertFalse(yaml.contains("采购出库单")); + } + + @Test + void contractContainsPlatformTruckWritesAndIndependentLooseboardDomain() throws Exception { + String yaml; + try (var input = getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml")) { + yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + assertTrue(yaml.contains("platform:overseas-business:truck:create")); + assertTrue(yaml.contains("platform:overseas-business:truck:edit")); + assertTrue(yaml.contains("/api/platform/v1/last-leg/local-looseboard-orders:")); + assertTrue(yaml.contains("platform:overseas-business:looseboard:list")); + assertTrue(yaml.contains("platform:overseas-business:looseboard:create")); + assertTrue(yaml.contains("platform:overseas-business:looseboard:edit")); + assertTrue(yaml.contains("platform:overseas-business:looseboard:status")); + assertFalse(yaml.contains("/api/platform/v1/last-leg/outbound")); + assertFalse(yaml.contains("/api/platform/v1/last-leg/delivery")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java index d9d7cdba0..8560aa6b6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java @@ -8,14 +8,16 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class OverseasProcurementSchemaContractTest { @Test - void baselineContainsOrderOutboundAndReviewHistoryTables() throws Exception { + void baselineContainsOrderAndOperationHistoryButNoProcurementOutbound() throws Exception { String sql; try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) { sql = new String(input.readAllBytes(), StandardCharsets.UTF_8); } assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order")); - assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound")); assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history")); - assertTrue(sql.contains("UNIQUE KEY uk_ll_overseas_procurement_outbound_no(customer_id,order_id,outbound_no)")); + assertTrue(sql.contains("operation_type VARCHAR(32) NOT NULL")); + assertTrue(sql.contains("change_detail JSON NULL")); + org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound")); + org.junit.jupiter.api.Assertions.assertFalse(sql.contains("outbound_quantity BIGINT")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java index 61559b9ee..c06909add 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java @@ -62,6 +62,15 @@ class OverseasProcurementControllerTest { verify(service).review("100", "HC1", "7", request); } + @Test + void platformUpdatePassesAuditedOperator() { + var request = new OverseasProcurementRequests.Update(0L, null, null, null, null, + null, null, null, null, null, null, null, null, null); + platform.update(user(0L, "PLATFORM", Set.of(OverseasProcurementService.MANAGE_PERMISSION)), + "100", "HC1", request); + verify(service).update("100", "HC1", "7", request); + } + private AuthenticatedUser user(long tenantId, String appCode, Set permissions) { return new AuthenticatedUser(7L, tenantId, tenantId, 8L, "operator", appCode, "WEB", permissions); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java index 308801744..f60b35062 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java @@ -3,8 +3,9 @@ package com.baiyue.lastleg.procurement.service; import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests; import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; -import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOutboundMapper; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper; +import com.baiyue.lastleg.masterdata.quote.service.PartnerWarehouseDirectory; +import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteResponses; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -23,21 +24,23 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class OverseasProcurementServiceTest { @Mock OverseasProcurementOrderMapper orders; - @Mock OverseasProcurementOutboundMapper outbounds; @Mock OverseasProcurementStatusHistoryMapper histories; + @Mock PartnerWarehouseDirectory warehouseDirectory; private OverseasProcurementService service; @BeforeEach void setUp() { service = new OverseasProcurementService(); ReflectionTestUtils.setField(service, "orders", orders); - ReflectionTestUtils.setField(service, "outbounds", outbounds); ReflectionTestUtils.setField(service, "histories", histories); + ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); } @@ -49,7 +52,6 @@ class OverseasProcurementServiceTest { return 1; }); when(orders.findNo(anyString(), anyString())).thenAnswer(ignored -> saved.get()); - when(outbounds.listOrder(anyString(), any())).thenReturn(List.of()); when(histories.listOrder(anyString(), any())).thenReturn(List.of()); var detail = service.create("100", "7", new OverseasProcurementRequests.Create( @@ -69,21 +71,75 @@ class OverseasProcurementServiceTest { 100L, null, null, null, null, null, null, null, null); ResponseStatusException error = assertThrows(ResponseStatusException.class, - () -> service.update("100", "HC1", request)); + () -> service.update("100", "HC1", "7", request)); assertEquals(409, error.getStatusCode().value()); } @Test - void outboundCannotExceedDerivedInventory() { - OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 80); + void explicitNullClearsOptionalFieldsAndWritesOperationHistory() { + OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0); + order.setProductUrl("https://example.test/old"); when(orders.lockNo("100", "HC1")).thenReturn(order); - when(orders.addOutbound(order.getId(), "100", 30)).thenReturn(0); - var request = new OverseasProcurementRequests.AddOutbound(30L, "OUT-1", null, null, - LocalDateTime.now(), null); + when(orders.findNo("100", "HC1")).thenReturn(order); + when(histories.listOrder("100", 1L)).thenReturn(List.of()); + var request = new OverseasProcurementRequests.Update(0L, null, null, + new ObjectMapper().nullNode(), new ObjectMapper().nullNode(), null, + null, null, null, null, null, null, null, null); - ResponseStatusException error = assertThrows(ResponseStatusException.class, - () -> service.addOutbound("100", "HC1", "7", request)); - assertEquals(409, error.getStatusCode().value()); + service.update("100", "HC1", "7", request); + + assertEquals(null, order.getProductUrl()); + verify(histories).insert(org.mockito.ArgumentMatchers.argThat(value -> + "UPDATE".equals(value.getOperationType()) && value.getChangeDetail().contains("productUrl"))); + } + + @Test + void omittedOptionalFieldKeepsExistingValue() { + OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0); + order.setProductUrl("https://example.test/keep"); + when(orders.lockNo("100", "HC1")).thenReturn(order); + when(orders.findNo("100", "HC1")).thenReturn(order); + when(histories.listOrder("100", 1L)).thenReturn(List.of()); + var request = new OverseasProcurementRequests.Update(0L, null, null, null, null, + null, null, null, null, null, null, null, null, null); + + service.update("100", "HC1", "7", request); + + assertEquals("https://example.test/keep", order.getProductUrl()); + verify(orders, never()).updateById(org.mockito.ArgumentMatchers.any()); + } + + @Test + void warehouseSelectionFreezesCanonicalCandidateName() { + OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0); + when(orders.lockNo("100", "HC1")).thenReturn(order); + when(orders.findNo("100", "HC1")).thenReturn(order); + when(histories.listOrder("100", 1L)).thenReturn(List.of()); + when(warehouseDirectory.required(10L)).thenReturn( + new OverseasQuoteResponses.PartnerWarehouseOption("10", "LAX-01", "洛杉矶采购仓", "US")); + ObjectMapper mapper = new ObjectMapper(); + var request = new OverseasProcurementRequests.Update(0L, null, null, null, null, + null, mapper.getNodeFactory().textNode("10"), mapper.getNodeFactory().textNode("伪造名称"), + null, null, null, null, null, null); + + service.update("100", "HC1", "7", request); + + assertEquals("10", order.getPurchaseWarehouseId()); + assertEquals("洛杉矶采购仓", order.getPurchaseWarehouseName()); + } + + @Test + void customerOptionsAreDeduplicatedSnapshotsFromProcurementOrders() { + OverseasProcurementOrder option = new OverseasProcurementOrder(); + option.setCustomerId("100"); + option.setCustomerName("Acme"); + when(orders.customerOptions("ac", 20)).thenReturn(List.of(option)); + + var values = service.customerOptions(" ac ", 20); + + assertEquals(1, values.size()); + assertEquals("100", values.get(0).customerId()); + assertEquals("Acme", values.get(0).customerName()); } private OverseasProcurementOrder order(String status, long inbound, long outbound) { @@ -93,7 +149,6 @@ class OverseasProcurementServiceTest { order.setOrderNo("HC1"); order.setStatus(status); order.setInboundQuantity(inbound); - order.setOutboundQuantity(outbound); order.setPurchaseQuantity(500L); order.setVersion(0L); order.setCreatedAt(LocalDateTime.now()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java index bab9745c3..551219f83 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java @@ -26,10 +26,13 @@ class SchemaChineseCommentContractTest { Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8); String incremental = Files.readString( Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8); + String overseasUnified = Files.readString( + Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8); List baselineTables = assertAllChineseComments(baseline, "基线 SQL", 55); assertAllChineseComments(incremental, "增量 SQL", 49); - assertCommentMigrationCovers(incremental, baselineTables); + assertAllChineseComments(overseasUnified, "海外统一增量 SQL", 10); + assertCommentMigrationCovers(incremental, overseasUnified, baselineTables); } @Test @@ -78,21 +81,48 @@ class SchemaChineseCommentContractTest { return tables; } - private static void assertCommentMigrationCovers(String incremental, List tables) { + private static void assertCommentMigrationCovers( + String incremental, + String overseasUnified, + List tables + ) { int marker = incremental.indexOf("2026-08-18 / 025 Chinese table and column comments"); assertTrue(marker >= 0, "增量 SQL 缺少 025 中文备注迁移"); String migration = incremental.substring(marker); for (TableDefinition table : tables) { - assertTrue(migration.contains("table_name='" + table.name() + "'"), + boolean createdInLegacy = hasCommentedCreate(migration, table.name()); + boolean createdInUnified = hasCommentedCreate(overseasUnified, table.name()); + assertTrue(createdInLegacy || createdInUnified + || migration.contains("table_name='" + table.name() + "'"), "025 备注迁移缺少表: " + table.name()); + if (createdInLegacy || createdInUnified) { + continue; + } for (String column : table.columns()) { - assertTrue(migration.contains("table_name='" + table.name() - + "' AND column_name='" + column + "'"), + boolean repaired = migration.contains("table_name='" + table.name() + + "' AND column_name='" + column + "'"); + boolean addedWithComment = hasCommentedAdd(migration, column) + || hasCommentedAdd(overseasUnified, column); + assertTrue(repaired || addedWithComment, "025 备注迁移缺少字段: " + table.name() + "." + column); } } } + private static boolean hasCommentedCreate(String sql, String table) { + return Pattern.compile( + "(?is)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+" + Pattern.quote(table) + + "\\s*\\(.*?\\)\\s*ENGINE\\s*=\\s*InnoDB\\s+COMMENT\\s*=\\s*'[^']*[\\u4e00-\\u9fff][^']*'\\s*;") + .matcher(sql).find(); + } + + private static boolean hasCommentedAdd(String sql, String column) { + return Pattern.compile( + "(?is)ADD\\s+COLUMN\\s+" + Pattern.quote(column) + + "\\b[^;]*?COMMENT\\s+''?[^;]*[\\u4e00-\\u9fff][^;]*''?") + .matcher(sql).find(); + } + private static boolean isConstraint(String definition) { String normalized = definition.stripLeading().toUpperCase(); return normalized.startsWith("PRIMARY ") || normalized.startsWith("UNIQUE ") diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index b2eae4f27..4ed18e1ee 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -7,12 +7,6 @@ import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; -import com.baiyue.lastleg.dropship.entity.DropshipCarton; -import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; -import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; -import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper; -import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; -import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -37,9 +31,6 @@ class LastlegOrderServiceTest { @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; - @Mock DropshipOrderProfileMapper dropshipProfiles; - @Mock DropshipSkuInventoryMapper dropshipSkuInventory; - @Mock DropshipCartonMapper dropshipCartons; LastlegOrderService service; @BeforeEach @@ -48,35 +39,23 @@ class LastlegOrderServiceTest { ReflectionTestUtils.setField(service, "orderMapper", orders); ReflectionTestUtils.setField(service, "detailMapper", details); ReflectionTestUtils.setField(service, "historyMapper", histories); - ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles); - ReflectionTestUtils.setField(service, "dropshipSkuInventory", dropshipSkuInventory); - ReflectionTestUtils.setField(service, "dropshipCartons", dropshipCartons); } @Test - void detailGroupsDropshipSkuAndCartonsByPallet() { + void detailDoesNotExposeDropshipCartonsOrInternalEventIds() { LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null); order.setOrderType("ONE_CLICK_DROPSHIP"); when(orders.selectOne(any())).thenReturn(order); when(details.selectList(any())).thenReturn(List.of()); when(histories.selectList(any())).thenReturn(List.of()); - DropshipOrderProfile first = profile(1L, "SOURCE-P-1", 101L, "WH-P-1"); - DropshipOrderProfile second = profile(2L, "SOURCE-P-2", 102L, "WH-P-2"); - when(dropshipProfiles.listOrder("100", 11L)).thenReturn(List.of(first, second)); - DropshipSkuInventory sku = new DropshipSkuInventory(); - sku.setProfileId(1L); sku.setSkuCode("SKU-A"); sku.setPackageCount(1L); sku.setProductQuantity(10L); - when(dropshipSkuInventory.listOrder("100", 11L)).thenReturn(List.of(sku)); - DropshipCarton carton = new DropshipCarton(); - carton.setProfileId(1L); carton.setSkuCode("SKU-A"); carton.setCartonMark("MARK-1"); - when(dropshipCartons.listOrder("100", 11L)).thenReturn(List.of(carton)); var response = service.detail("100", 11L); - assertEquals(2, response.dropshipPallets().size()); - assertEquals("SOURCE-P-1", response.dropshipPallets().get(0).sourcePalletNo()); - assertEquals(1, response.dropshipPallets().get(0).skuInventory().size()); - assertEquals("MARK-1", response.dropshipPallets().get(0).cartons().get(0).getCartonMark()); - assertTrue(response.dropshipPallets().get(1).skuInventory().isEmpty()); + assertEquals(11L, response.order().id()); + assertTrue(java.util.Arrays.stream(response.getClass().getRecordComponents()) + .noneMatch(component -> "dropshipPallets".equals(component.getName()))); + assertTrue(java.util.Arrays.stream(com.baiyue.lastleg.dto.LastlegOrderDetailResponse.History.class.getRecordComponents()) + .noneMatch(component -> "eventId".equals(component.getName()))); } @Test @@ -133,12 +112,4 @@ class LastlegOrderServiceTest { return order; } - private static DropshipOrderProfile profile(long id, String sourcePalletNo, - long warehousePalletId, String palletNo) { - DropshipOrderProfile profile = new DropshipOrderProfile(); - profile.setId(id); profile.setSourcePalletNo(sourcePalletNo); - profile.setWarehousePalletId(warehousePalletId); profile.setPalletNo(palletNo); - profile.setStatus("RECEIVED"); - return profile; - } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index 58ee4bcef..f4b449c62 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -206,6 +206,7 @@ class WarehouseTransferServiceTest { when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); when(inventoryAllocations.lockTransfer("TENANT-A", 21L)).thenReturn(List.of(allocation)); + when(inventoryCommands.lockBalance("TENANT-A", 601L)).thenReturn(balance(601L, 101L, "IN-1")); when(orders.selectById(31L)).thenReturn(tailOrder); when(transferItems.selectList(any())).thenReturn(List.of()); when(sourceOrders.selectList(any())).thenReturn(List.of()); @@ -237,6 +238,7 @@ class WarehouseTransferServiceTest { ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn.class); verify(inventory).stockIn(eq("TENANT-A"), eq("USER-1"), stockIn.capture()); assertEquals(5, stockIn.getValue().packages()); + assertEquals("SKU-TRANSFER", stockIn.getValue().skuCode()); assertEquals("WTR-1", stockIn.getValue().businessOrderNo()); assertEquals("WAREHOUSE_TRANSFER_RECEIPT:WTR-1:501", stockIn.getValue().idempotencyKey()); assertEquals(WarehouseTransferService.RECEIVED, transfer.getStatus()); @@ -350,7 +352,7 @@ class WarehouseTransferServiceTest { private static InventoryBalance balance(long id, long inboundOrderId, String inboundCode) { InventoryBalance balance = new InventoryBalance(); balance.setId(id); balance.setCustomerId("TENANT-A"); balance.setInboundOrderId(inboundOrderId); - balance.setInboundCode(inboundCode); + balance.setInboundCode(inboundCode); balance.setSkuCode("SKU-TRANSFER"); return balance; } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java index e70f33db3..fdcb0caf1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java @@ -50,6 +50,25 @@ class UsTruckOrderControllerTest { verify(service).changeStatus("100", "TK1", "7", request); } + @Test + void platformCreateUsesExplicitCustomerAndCreatePermission() { + var request = new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles", null, + "Ontario", null, "40HQ", 1, 0, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, + null, "USD", "Alice", "+1-555-0100", java.time.LocalDate.now(), null, "create-key"); + platform.create(user(0L, "PLATFORM", Set.of(com.baiyue.lastleg.security.OverseasPermissions.TRUCK_CREATE)), + "100", request); + verify(service).create("100", "7", request); + } + + @Test + void platformUpdateUsesExplicitCustomerAndEditPermission() { + var request = new UsTruckOrderRequests.Update(0L, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null); + platform.update(user(0L, "PLATFORM", Set.of(com.baiyue.lastleg.security.OverseasPermissions.TRUCK_EDIT)), + "100", "TK1", request); + verify(service).update("100", "TK1", "7", request); + } + private UsTruckOrderRequests.Query query() { return new UsTruckOrderRequests.Query(1, 20, null, null, null, null, null, null, null); } private AuthenticatedUser user(long tenant, String app, Set permissions) { return new AuthenticatedUser(7L, tenant, tenant, 8L, "operator", app, "WEB", permissions); diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 298df8d32..df758b85f 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -1517,6 +1517,22 @@ PRIMARY KEY(id), UNIQUE KEY uk_ll_us_truck_order_pallet_no(customer_id,order_id, KEY idx_ll_us_truck_order_pallet_list(customer_id,order_id,status) ) ENGINE=InnoDB COMMENT='美国卡车订单托盘实收记录';+ -- ============================================================================ +CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton ( + id BIGINT NOT NULL COMMENT '箱唛作业主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_id BIGINT NULL COMMENT '海外首扫确权后的尾程订单ID', source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入库明细ID', + sku_code VARCHAR(128) NULL COMMENT 'SKU编码快照', carton_mark VARCHAR(128) NOT NULL COMMENT '国内冻结箱唛', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID', + line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID', + warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID', + status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark), + KEY idx_ll_inbound_carton_plan(plan_id,status), KEY idx_ll_inbound_carton_pallet(plan_pallet_id,status), + KEY idx_ll_inbound_carton_order(order_id,status) +) ENGINE=InnoDB COMMENT='柜次冻结箱唛及海外入库箱级作业账'; + -- 2026-08-18 / 025 Chinese table and column comments for Lastleg and Warehouse. -- Idempotently updates metadata for databases where CREATE TABLE already ran. -- ============================================================================ @@ -1636,6 +1652,50 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_loading_plan_id' AND COALESCE(column_comment,'') <> '来源装柜计划 ID'), 'ALTER TABLE ll_order MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT ''来源装柜计划 ID''', 'SELECT 1')); PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id' AND COALESCE(column_comment,'') <> '来源头程货柜预订ID'), 'ALTER TABLE ll_order MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND COALESCE(table_comment,'') <> '柜次冻结箱唛及海外入库箱级作业账'), 'ALTER TABLE ll_inbound_plan_carton COMMENT = ''柜次冻结箱唛及海外入库箱级作业账''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='id' AND COALESCE(column_comment,'') <> '箱唛作业主键'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN id BIGINT NOT NULL COMMENT ''箱唛作业主键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='order_id' AND COALESCE(column_comment,'') <> '海外首扫确权后的尾程订单ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN order_id BIGINT NULL COMMENT ''海外首扫确权后的尾程订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='source_inbound_detail_id' AND COALESCE(column_comment,'') <> '来源入库明细ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT ''来源入库明细ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码快照'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT ''SKU编码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='carton_mark' AND COALESCE(column_comment,'') <> '国内冻结箱唛'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN carton_mark VARCHAR(128) NOT NULL COMMENT ''国内冻结箱唛''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓ID快照'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓ID快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称快照'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '海外入库计划ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN plan_id BIGINT NULL COMMENT ''海外入库计划ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='line_id' AND COALESCE(column_comment,'') <> '入库计划明细ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN line_id BIGINT NULL COMMENT ''入库计划明细ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='plan_pallet_id' AND COALESCE(column_comment,'') <> '海外计划托盘ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN plan_pallet_id BIGINT NULL COMMENT ''海外计划托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '海外实物托盘ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''海外实物托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='warehouse_location_id' AND COALESCE(column_comment,'') <> '海外库位ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN warehouse_location_id BIGINT NULL COMMENT ''海外库位ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='pda_task_id' AND COALESCE(column_comment,'') <> '首次扫码PDA任务ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN pda_task_id BIGINT NULL COMMENT ''首次扫码PDA任务ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='status' AND COALESCE(column_comment,'') <> '箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT ''箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='bound_at' AND COALESCE(column_comment,'') <> '绑定托盘时间'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN bound_at DATETIME(3) NULL COMMENT ''绑定托盘时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='received_at' AND COALESCE(column_comment,'') <> '完成入库时间'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN received_at DATETIME(3) NULL COMMENT ''完成入库时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_loading_plan_version' AND COALESCE(column_comment,'') <> '来源装柜计划版本'), 'ALTER TABLE ll_order MODIFY COLUMN source_loading_plan_version BIGINT COMMENT ''来源装柜计划版本''', 'SELECT 1')); PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_handover_version' AND COALESCE(column_comment,'') <> '交接版本'), 'ALTER TABLE ll_order MODIFY COLUMN source_handover_version BIGINT COMMENT ''交接版本''', 'SELECT 1')); @@ -3577,3 +3637,60 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD UNIQUE KEY uk_ll_inbound_plan_source_order (customer_id,source_order_id)')); PREPARE ll_inbound_plan_source_order_unique_027 FROM @ddl; EXECUTE ll_inbound_plan_source_order_unique_027; DEALLOCATE PREPARE ll_inbound_plan_source_order_unique_027; + +-- ============================================================================ +-- 2026-08-20 / 028 +-- Phase-4 reverse event identity, unload evidence and SKU/history snapshots. +-- ============================================================================ +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), + 'ALTER TABLE ll_order MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID''', + 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_file_ids'), + 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表''', + 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表'' AFTER completed_at')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_uploaded_at'), + 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间''', + 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间'' AFTER unload_photo_file_ids')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='estimated_empty_return_at'), + 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)''', + 'ALTER TABLE ll_inbound_pda_task ADD COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)'' AFTER unload_photo_uploaded_at')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='sku_code'), + 'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码;无SKU使用UNSPECIFIED''', + 'ALTER TABLE ll_inventory_balance ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码;无SKU使用UNSPECIFIED'' AFTER inbound_code')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='product_name_zh'), + 'ALTER TABLE ll_inventory_balance MODIFY COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照''', + 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照'' AFTER sku_code')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='product_name_en'), + 'ALTER TABLE ll_inventory_balance MODIFY COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照''', + 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照'' AFTER product_name_zh')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='sku_code'), + 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码快照''', + 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码快照'' AFTER inbound_code')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='product_name_zh'), + 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照''', + 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照'' AFTER sku_code')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='product_name_en'), + 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照''', + 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照'' AFTER product_name_zh')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='operation_type'), + 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN operation_type VARCHAR(32) NOT NULL COMMENT ''操作类型:CREATE、REVIEW或UPDATE''', + 'ALTER TABLE ll_overseas_procurement_status_history ADD COLUMN operation_type VARCHAR(32) NOT NULL DEFAULT ''UPDATE'' COMMENT ''操作类型:CREATE、REVIEW或UPDATE'' AFTER customer_id')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='change_detail'), + 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN change_detail JSON NULL COMMENT ''字段变更前后值JSON''', + 'ALTER TABLE ll_overseas_procurement_status_history ADD COLUMN change_detail JSON NULL COMMENT ''字段变更前后值JSON'' AFTER operator_id')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; diff --git a/scripts/finance-deployment-contract.test.mjs b/scripts/finance-deployment-contract.test.mjs index 7e20e5b37..87c520265 100644 --- a/scripts/finance-deployment-contract.test.mjs +++ b/scripts/finance-deployment-contract.test.mjs @@ -9,10 +9,10 @@ test('Jenkins pipelines build finance as an independent service', async () => { const controller = await read('Jenkinsfile') const service = await read('Jenkinsfile.service') - assert.match(controller, /'gateway', 'auth', 'iam', 'firstleg', 'finance'/) + assert.match(controller, /'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance'/) assert.match(controller, /'finance': 'SERVICE_FINANCE'/) assert.match(controller, /booleanParam\(name: 'SERVICE_FINANCE'/) - assert.match(service, /'gateway', 'auth', 'iam', 'firstleg', 'finance'/) + assert.match(service, /'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance'/) assert.match(service, /'finance': 'finance\/target\/finance-service-1\.0\.0-SNAPSHOT\.jar'/) }) diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs new file mode 100644 index 000000000..1c1f3b10f --- /dev/null +++ b/scripts/overseas-deployment-contract.test.mjs @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const read = async (path) => (await readFile(new URL(`../${path}`, import.meta.url), 'utf8')) + .replaceAll('\r\n', '\n') + +test('Jenkins pipelines recognize all overseas deployable modules', async () => { + const controller = await read('Jenkinsfile') + const service = await read('Jenkinsfile.service') + for (const module of ['gateway-overseas', 'lastleg-service', 'warehouse-overseas-service']) { + assert.match(controller, new RegExp(`'${module}'`)) + assert.match(service, new RegExp(`'${module}'`)) + } + assert.match(controller, /'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS'/) + assert.match(controller, /'lastleg-service': 'SERVICE_LASTLEG'/) + assert.match(controller, /'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS'/) + assert.match(service, /gateway-overseas\/target\/gateway-overseas-1\.0\.0-SNAPSHOT\.jar/) + assert.match(service, /lastleg-service\/target\/lastleg-service-1\.0\.0-SNAPSHOT\.jar/) + assert.match(service, /warehouse-overseas-service\/target\/warehouse-overseas-service-1\.0\.0-SNAPSHOT\.jar/) +}) + +test('service map freezes unique internal ports and moves event bridge away from 9300', async () => { + const map = await read('deploy/service-map.yml') + assert.match(map, /^ gateway-overseas:[\s\S]*?port: 9300$/m) + assert.match(map, /^ lastleg-service:[\s\S]*?port: 9303$/m) + assert.match(map, /^ warehouse-overseas-service:[\s\S]*?port: 9305$/m) + assert.match(map, /^ event-bridge-service:[\s\S]*?port: 9312$/m) + assert.match(map, /^ gateway-overseas:[\s\S]*?regions: \[OVERSEAS\]$/m) + assert.match(map, /^ lastleg-service:[\s\S]*?regions: \[OVERSEAS\]$/m) + assert.match(map, /^ warehouse-overseas-service:[\s\S]*?regions: \[OVERSEAS\]$/m) + assert.match(map, /^ event-bridge-service:[\s\S]*?regions: \[CN, OVERSEAS\]$/m) +}) + +test('overseas compose is isolated from CN defaults and disables implicit schema execution', async () => { + const compose = await read('deploy/docker-compose.overseas.yml') + assert.match(compose, /^name: firstleg-\$\{DEPLOY_ENV:-test\}-overseas$/m) + assert.match(compose, /NACOS_NAMESPACE: \$\{OVERSEAS_NACOS_NAMESPACE:-prod-overseas\}/) + assert.match(compose, /REGION_CODE: OVERSEAS/) + assert.match(compose, /SPRING_SQL_INIT_MODE: never/) + assert.match(compose, /^ gateway-overseas:\s*$/m) + assert.match(compose, /^ lastleg-service:\s*$/m) + assert.match(compose, /^ warehouse-overseas-service:\s*$/m) + assert.match(compose, /^ event-bridge-service:\s*$/m) + assert.match(compose, /EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312/) +}) + +test('CN and overseas event bridges have explicit opposite-region consumers', async () => { + const cn = await read('config/nacos/cn/event-bridge-service.yml') + const overseas = await read('config/nacos/overseas/event-bridge-service.yml') + const firstleg = await read('config/nacos/cn/firstleg-service.yml') + const lastleg = await read('config/nacos/overseas/lastleg-service.yml') + const scripts = `${await read('deploy/scripts/deploy-built-service.sh')}\n${await read('deploy/scripts/deploy-services.sh')}` + assert.match(cn, /EVENT_CONSUMER_URL:http:\/\/firstleg:9202\/internal\/firstleg\/v1\/container-events/) + assert.match(overseas, /EVENT_CONSUMER_URL:http:\/\/lastleg-service:9303\/internal\/lastleg\/events/) + assert.match(firstleg, /LASTLEG_EVENT_BRIDGE_URL/) + assert.match(firstleg, /LASTLEG_OUTBOX_RELAY_LEASE_SECONDS/) + assert.match(lastleg, /FIRSTLEG_EVENT_BRIDGE_URL/) + assert.match(lastleg, /WAREHOUSE_OVERSEAS_SERVICE_URL:http:\/\/warehouse-overseas-service:9305/) + assert.match(scripts, /FIRSTLEG_EVENT_BRIDGE_URL/) + assert.match(scripts, /LASTLEG_EVENT_BRIDGE_URL/) + assert.match(scripts, /docker-compose\.overseas\.yml/) + assert.match(scripts, /REGION_CODE=CN 或 REGION_CODE=OVERSEAS/) + assert.match(scripts, /env_file_value REGION_CODE/) + for (const key of [ + 'NACOS_SERVER_ADDR', 'OVERSEAS_NACOS_NAMESPACE', 'INTERNAL_SERVICE_TOKEN', + 'JWT_ISSUER', 'OVERSEAS_JWT_AUDIENCE', 'JWT_JWK_SET_URI' + ]) assert.match(scripts, new RegExp(key)) +}) diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md index f3e53ff47..c0357deda 100644 --- a/warehouse-overseas-service/AGENTS.md +++ b/warehouse-overseas-service/AGENTS.md @@ -21,6 +21,7 @@ ## 数据与安全规则 - 平台和商户接口必须分别执行访问域与租户范围校验;客户端传入的 `tenantId` 不能作为唯一授权依据。 +- 平台维护的海外库位与实物托盘是公共资源,固定使用 `customer_id=0`;平台资源 API 不接收目标客户参数。资源锁记录仍使用真实业务客户 ID 隔离,但其托盘、库位查找和状态更新必须落到公共资源作用域。 - 资源锁定以业务幂等键和 reservation 标识防重;释放/报废操作必须验证当前状态。 - 内部资源接口必须校验服务令牌;普通用户不得直接调用。 diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java index e8bcfb10f..d6b1004d8 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java @@ -9,6 +9,7 @@ import com.baiyue.warehouse.dto.WarehouseDetailResponse; import com.baiyue.warehouse.dto.WarehouseRequests; import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.service.WarehouseApplicationService; +import com.baiyue.warehouse.security.OverseasWarehousePermissions; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -17,6 +18,7 @@ import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -44,7 +46,7 @@ public class PlatformWarehouseController { @Operation( summary = "Create warehouse / 新建仓库", - description = "Creates a warehouse, address and service capabilities for the target tenant. / 为目标租户创建仓库、地址和服务能力。" + description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_CREATE + ". Creates a warehouse, address and service capabilities for the target customer." ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Created successfully / 创建成功"), @@ -53,6 +55,7 @@ public class PlatformWarehouseController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在") }) @PostMapping + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_CREATE + "')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody WarehouseRequests.SaveRequest request) { requirePlatform(user); @@ -62,7 +65,7 @@ public class PlatformWarehouseController { @Operation( summary = "Update warehouse / 更新仓库", - description = "Updates the warehouse and replaces its address and capabilities. / 更新仓库,并整体替换地址和服务能力。" + description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_EDIT + ". Updates the warehouse and replaces its address and capabilities." ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Updated successfully / 更新成功"), @@ -72,6 +75,7 @@ public class PlatformWarehouseController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在") }) @PutMapping("/{id}") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_EDIT + "')") public ApiResponse update( @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @@ -87,7 +91,7 @@ public class PlatformWarehouseController { @Operation( summary = "List tenant warehouses / 查询租户仓库", - description = "Returns a tenant-scoped warehouse page with optional country, capability and status filters. / 按租户分页查询仓库,可按国家、能力和状态筛选。" + description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST + ". Returns a customer-scoped warehouse page with optional filters." ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), @@ -95,6 +99,7 @@ public class PlatformWarehouseController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限") }) @GetMapping + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "')") public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @@ -107,7 +112,7 @@ public class PlatformWarehouseController { @Operation( summary = "Get tenant warehouse details / 查询租户仓库详情", - description = "Returns the warehouse, addresses and service capabilities in the target tenant. / 返回目标租户下的仓库、地址及服务能力。" + description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST + ". Returns the warehouse, addresses and service capabilities for the target customer." ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), @@ -115,6 +120,7 @@ public class PlatformWarehouseController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在") }) @GetMapping("/{id}") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "')") public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @@ -128,7 +134,7 @@ public class PlatformWarehouseController { @Operation( summary = "Enable or disable warehouse / 启用或停用仓库", - description = "Changes warehouse status to ACTIVE or INACTIVE. / 将仓库状态切换为 ACTIVE 或 INACTIVE。" + description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_STATUS + ". Changes warehouse status to ACTIVE or INACTIVE." ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Status changed / 状态变更成功"), @@ -137,6 +143,7 @@ public class PlatformWarehouseController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在") }) @PatchMapping("/{id}/status") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_STATUS + "')") public ApiResponse status( @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @@ -152,7 +159,7 @@ public class PlatformWarehouseController { } private static void requirePlatform(AuthenticatedUser user) { - if (user == null || !"PLATFORM".equals(user.appCode())) { + if (user == null || !"PLATFORM".equals(user.appCode()) || user.tenantId() == null || user.tenantId() != 0L) { throw new BizException(ResultCode.FORBIDDEN, "Platform application required / 仅允许平台端访问"); } } @@ -163,4 +170,3 @@ public class PlatformWarehouseController { } } } - diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java index 7e714246b..be951ca52 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java @@ -10,10 +10,13 @@ import com.baiyue.warehouse.dto.PalletRequests; import com.baiyue.warehouse.entity.WarehouseLocationEntity; import com.baiyue.warehouse.entity.WarehousePalletEntity; import com.baiyue.warehouse.service.WarehouseResourceService; +import com.baiyue.warehouse.security.OverseasWarehousePermissions; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.annotation.Resource; -import jakarta.validation.constraints.NotNull; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -28,104 +31,114 @@ import org.springframework.web.bind.annotation.RestController; @Validated @RestController @RequestMapping("/api/platform/v1/warehouse-admin/{warehouseId}") +@Tag(name = "Platform overseas warehouse resources") public class PlatformWarehouseResourceController { @Resource private WarehouseResourceService service; @PostMapping("/locations") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.LOCATION_CREATE + "')") + @Operation(summary = "Create a warehouse location", description = "Required authority: " + OverseasWarehousePermissions.LOCATION_CREATE) public ApiResponse createLocation(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @Valid @RequestBody LocationRequests.SaveRequest request) { - requirePlatform(user, customerId); - return ApiResponse.success(service.createLocation(customerId, warehouseId, request)); + requirePlatform(user); + return ApiResponse.success(service.createLocation(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, request)); } @PutMapping("/locations/{id}") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.LOCATION_EDIT + "')") + @Operation(summary = "Update a warehouse location", description = "Required authority: " + OverseasWarehousePermissions.LOCATION_EDIT) public ApiResponse updateLocation(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @PathVariable long id, @Valid @RequestBody LocationRequests.SaveRequest request) { - requirePlatform(user, customerId); - service.updateLocation(customerId, warehouseId, id, request); + requirePlatform(user); + service.updateLocation(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, id, request); return ApiResponse.success(null); } @GetMapping("/locations") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.LOCATION_LIST + "')") + @Operation(summary = "Page warehouse locations", description = "Required authority: " + OverseasWarehousePermissions.LOCATION_LIST) public ApiResponse> locations( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId, @Valid LocationRequests.QueryRequest query) { - requirePlatform(user, customerId); - return ApiResponse.success(service.locations(customerId, warehouseId, query)); + requirePlatform(user); + return ApiResponse.success(service.locations(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, query)); } @GetMapping("/locations/{id}") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.LOCATION_LIST + "')") + @Operation(summary = "Get a warehouse location", description = "Required authority: " + OverseasWarehousePermissions.LOCATION_LIST) public ApiResponse location(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @PathVariable long id) { - requirePlatform(user, customerId); - return ApiResponse.success(service.location(customerId, warehouseId, id)); + requirePlatform(user); + return ApiResponse.success(service.location(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, id)); } @DeleteMapping("/locations/{id}") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.LOCATION_DELETE + "')") + @Operation(summary = "Delete a warehouse location", description = "Required authority: " + OverseasWarehousePermissions.LOCATION_DELETE) public ApiResponse deleteLocation(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @PathVariable long id) { - requirePlatform(user, customerId); - service.deleteLocation(customerId, warehouseId, id); + requirePlatform(user); + service.deleteLocation(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, id); return ApiResponse.success(null); } @PostMapping("/pallets") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_CREATE + "')") + @Operation(summary = "Create a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_CREATE) public ApiResponse createPallet(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @Valid @RequestBody PalletRequests.SaveRequest request) { - requirePlatform(user, customerId); - return ApiResponse.success(service.createPallet(customerId, warehouseId, request)); + requirePlatform(user); + return ApiResponse.success(service.createPallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, request)); } @PutMapping("/pallets/{id}") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_EDIT + "')") + @Operation(summary = "Update a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_EDIT) public ApiResponse updatePallet(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @PathVariable long id, @Valid @RequestBody PalletRequests.SaveRequest request) { - requirePlatform(user, customerId); - service.updatePallet(customerId, warehouseId, id, request); + requirePlatform(user); + service.updatePallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, id, request); return ApiResponse.success(null); } @GetMapping("/pallets") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_LIST + "')") + @Operation(summary = "Page warehouse pallets", description = "Required authority: " + OverseasWarehousePermissions.PALLET_LIST) public ApiResponse> pallets( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId, @Valid PalletRequests.QueryRequest query) { - requirePlatform(user, customerId); - return ApiResponse.success(service.pallets(customerId, warehouseId, query)); + requirePlatform(user); + return ApiResponse.success(service.pallets(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, query)); } @GetMapping("/pallets/{id}") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_LIST + "')") + @Operation(summary = "Get a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_LIST) public ApiResponse pallet(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @PathVariable long id) { - requirePlatform(user, customerId); - return ApiResponse.success(service.pallet(customerId, warehouseId, id)); + requirePlatform(user); + return ApiResponse.success(service.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, id)); } @DeleteMapping("/pallets/{id}") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_DELETE + "')") + @Operation(summary = "Delete a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_DELETE) public ApiResponse deletePallet(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @PathVariable long id) { - requirePlatform(user, customerId); - service.deletePallet(customerId, warehouseId, id); + requirePlatform(user); + service.deletePallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, id); return ApiResponse.success(null); } - private static void requirePlatform(AuthenticatedUser user, Long customerId) { - if (user == null || !"PLATFORM".equals(user.appCode())) { + private static void requirePlatform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equals(user.appCode()) || user.tenantId() == null || user.tenantId() != 0L) { throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); } - if (customerId == null || customerId <= 0) { - throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户"); - } } } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java index 03933ca81..a2fb28afa 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java @@ -7,11 +7,13 @@ import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; import com.baiyue.warehouse.mapper.WarehouseLocationMapper; import com.baiyue.warehouse.mapper.WarehousePalletMapper; import com.baiyue.warehouse.mapper.WarehouseResourceLockMapper; +import com.baiyue.warehouse.service.WarehouseResourceService; import org.springframework.stereotype.Repository; import jakarta.annotation.Resource; @Repository public class WarehouseResourceLockRepository { + private static final long PUBLIC_RESOURCE_SCOPE = WarehouseResourceService.PUBLIC_RESOURCE_SCOPE; @Resource private WarehouseResourceLockMapper locks; @Resource private WarehousePalletMapper pallets; @Resource private WarehouseLocationMapper locations; @@ -32,58 +34,58 @@ public class WarehouseResourceLockRepository { public WarehousePalletEntity findPallet(long customerId, long palletId) { return pallets.selectOne(new LambdaQueryWrapper() - .eq(WarehousePalletEntity::getCustomerId, customerId) + .eq(WarehousePalletEntity::getCustomerId, PUBLIC_RESOURCE_SCOPE) .eq(WarehousePalletEntity::getId, palletId)); } public WarehousePalletEntity findPallet(long customerId, long warehouseId, long palletId) { return pallets.selectOne(new LambdaQueryWrapper() - .eq(WarehousePalletEntity::getCustomerId, customerId) + .eq(WarehousePalletEntity::getCustomerId, PUBLIC_RESOURCE_SCOPE) .eq(WarehousePalletEntity::getWarehouseId, warehouseId) .eq(WarehousePalletEntity::getId, palletId)); } public WarehouseLocationEntity findLocation(long customerId, long warehouseId, long locationId) { return locations.selectOne(new LambdaQueryWrapper() - .eq(WarehouseLocationEntity::getCustomerId, customerId) + .eq(WarehouseLocationEntity::getCustomerId, PUBLIC_RESOURCE_SCOPE) .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) .eq(WarehouseLocationEntity::getId, locationId)); } public int occupyPallet(long customerId, long warehouseId, long palletId, long locationId) { - return locks.occupyPallet(customerId, warehouseId, palletId, locationId); + return locks.occupyPallet(PUBLIC_RESOURCE_SCOPE, warehouseId, palletId, locationId); } public int occupyLocation(long customerId, long warehouseId, long locationId) { - return locks.occupyLocation(customerId, warehouseId, locationId); + return locks.occupyLocation(PUBLIC_RESOURCE_SCOPE, warehouseId, locationId); } public void insert(WarehouseResourceLockEntity lock) { locks.insert(lock); } public int releasePallet(WarehouseResourceLockEntity lock) { - return locks.releasePallet(lock.getCustomerId(), lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId()); + return locks.releasePallet(PUBLIC_RESOURCE_SCOPE, lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId()); } public int releasePalletAtVirtualLocation(WarehouseResourceLockEntity lock) { - return locks.releasePalletAtVirtualLocation(lock.getCustomerId(), lock.getWarehouseId(), + return locks.releasePalletAtVirtualLocation(PUBLIC_RESOURCE_SCOPE, lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId()); } public int scrapPallet(WarehouseResourceLockEntity lock) { - return locks.scrapPallet(lock.getCustomerId(), lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId()); + return locks.scrapPallet(PUBLIC_RESOURCE_SCOPE, lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId()); } public int scrapPalletWithoutLock(long customerId, long palletId) { - return locks.scrapPalletWithoutLock(customerId, palletId); + return locks.scrapPalletWithoutLock(PUBLIC_RESOURCE_SCOPE, palletId); } public int scrapPalletAtVirtualLocation(WarehouseResourceLockEntity lock) { - return locks.scrapPalletAtVirtualLocation(lock.getCustomerId(), lock.getWarehouseId(), + return locks.scrapPalletAtVirtualLocation(PUBLIC_RESOURCE_SCOPE, lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId()); } public int releaseLocation(WarehouseResourceLockEntity lock) { - return locks.releaseLocation(lock.getCustomerId(), lock.getWarehouseId(), lock.getLocationId()); + return locks.releaseLocation(PUBLIC_RESOURCE_SCOPE, lock.getWarehouseId(), lock.getLocationId()); } public int close(WarehouseResourceLockEntity lock, String status) { return locks.closeLock(lock.getId(), status); } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java index 7c02c7dbf..cdd5dc0f0 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java @@ -45,6 +45,10 @@ public class WarehouseResourceRepository { .eq(WarehouseEntity::getId, warehouseId)); } + public WarehouseEntity findWarehouseById(long warehouseId) { + return warehouses.selectById(warehouseId); + } + public WarehouseLocationEntity findDefaultVirtualLocation(long customerId, long warehouseId) { return locations.selectOne(new LambdaQueryWrapper() .eq(WarehouseLocationEntity::getCustomerId, customerId) diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/OverseasWarehousePermissions.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/OverseasWarehousePermissions.java new file mode 100644 index 000000000..acac5005f --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/OverseasWarehousePermissions.java @@ -0,0 +1,19 @@ +package com.baiyue.warehouse.security; + +/** Canonical Admin authorities for overseas warehouse resources. */ +public final class OverseasWarehousePermissions { + public static final String WAREHOUSE_LIST = "platform:overseas-warehouse:warehouse:list"; + public static final String WAREHOUSE_CREATE = "platform:overseas-warehouse:warehouse:create"; + public static final String WAREHOUSE_EDIT = "platform:overseas-warehouse:warehouse:edit"; + public static final String WAREHOUSE_STATUS = "platform:overseas-warehouse:warehouse:status"; + public static final String LOCATION_LIST = "platform:overseas-warehouse:location:list"; + public static final String LOCATION_CREATE = "platform:overseas-warehouse:location:create"; + public static final String LOCATION_EDIT = "platform:overseas-warehouse:location:edit"; + public static final String LOCATION_DELETE = "platform:overseas-warehouse:location:delete"; + public static final String PALLET_LIST = "platform:overseas-warehouse:pallet:list"; + public static final String PALLET_CREATE = "platform:overseas-warehouse:pallet:create"; + public static final String PALLET_EDIT = "platform:overseas-warehouse:pallet:edit"; + public static final String PALLET_DELETE = "platform:overseas-warehouse:pallet:delete"; + + private OverseasWarehousePermissions() { } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java index b71345643..00695b535 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -22,6 +22,7 @@ import org.springframework.util.StringUtils; @Service public class WarehouseResourceService { + public static final long PUBLIC_RESOURCE_SCOPE = 0L; private static final Set LOCATION_STATUSES = Set.of("ACTIVE", "IDLE", "OCCUPIED", "INACTIVE"); private static final Set PALLET_STATUSES = Set.of("IDLE", "OCCUPIED", "SCRAPPED"); @@ -187,13 +188,20 @@ public class WarehouseResourceService { } private void requireWarehouse(long customerId, long warehouseId) { + if (customerId == PUBLIC_RESOURCE_SCOPE) { + if (repository.findWarehouseById(warehouseId) == null) { + throw new BizException(ResultCode.NOT_FOUND, "Warehouse not found"); + } + return; + } if (!repository.warehouseOwned(customerId, warehouseId)) { throw new BizException(ResultCode.NOT_FOUND, "当前租户下仓库不存在"); } } private WarehouseEntity requireWarehouseEntity(long customerId, long warehouseId) { - WarehouseEntity warehouse = repository.findWarehouse(customerId, warehouseId); + WarehouseEntity warehouse = customerId == PUBLIC_RESOURCE_SCOPE + ? repository.findWarehouseById(warehouseId) : repository.findWarehouse(customerId, warehouseId); if (warehouse == null) { throw new BizException(ResultCode.NOT_FOUND, "Tenant warehouse not found"); } diff --git a/warehouse-overseas-service/src/main/resources/application.yml b/warehouse-overseas-service/src/main/resources/application.yml index 1980faedf..212a72cae 100644 --- a/warehouse-overseas-service/src/main/resources/application.yml +++ b/warehouse-overseas-service/src/main/resources/application.yml @@ -18,17 +18,17 @@ spring: - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} username: ${NACOS_USERNAME:nacos} password: ${NACOS_PASSWORD:nacos} discovery: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} namespace: ${NACOS_NAMESPACE:public} config: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} namespace: ${NACOS_NAMESPACE:public} datasource: driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java index 4c1f680e1..1da6a1cb5 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java @@ -34,20 +34,29 @@ class WarehouseResourceControllerTest { PlatformWarehouseResourceController controller = platformController(); assertThatThrownBy(() -> controller.location(user(10001L, "MERCHANT"), - 10002L, 20001L, 30001L)) + 20001L, 30001L)) .isInstanceOf(BizException.class) .hasMessageContaining("平台端"); } @Test - void platformApiUsesExplicitTargetTenant() { + void platformApiUsesPublicResourceScope() { PlatformWarehouseResourceController controller = platformController(); WarehouseLocationEntity expected = new WarehouseLocationEntity(); - when(service.location(10002L, 20001L, 30001L)).thenReturn(expected); + when(service.location(0L, 20001L, 30001L)).thenReturn(expected); - controller.location(user(1L, "PLATFORM"), 10002L, 20001L, 30001L); + controller.location(user(0L, "PLATFORM"), 20001L, 30001L); - verify(service).location(10002L, 20001L, 30001L); + verify(service).location(0L, 20001L, 30001L); + } + + @Test + void platformApiRejectsPlatformAppWithCustomerScope() { + PlatformWarehouseResourceController controller = platformController(); + + assertThatThrownBy(() -> controller.location(user(10001L, "PLATFORM"), 20001L, 30001L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("平台端"); } private static AuthenticatedUser user(long customerId, String appCode) { diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java new file mode 100644 index 000000000..65588f077 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java @@ -0,0 +1,41 @@ +package com.baiyue.warehouse.controller; + +import io.swagger.v3.oas.annotations.Operation; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class WarehouseResourcePermissionContractTest { + private static final String PREFIX = "platform:overseas-warehouse:"; + + @Test + void locationAndPalletEndpointsUseOnlyCanonicalAuthorities() { + Map methods = Arrays.stream(PlatformWarehouseResourceController.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(PreAuthorize.class)) + .collect(Collectors.toMap(Method::getName, method -> method)); + + assertPermission(methods.get("locations"), PREFIX + "location:list"); + assertPermission(methods.get("location"), PREFIX + "location:list"); + assertPermission(methods.get("createLocation"), PREFIX + "location:create"); + assertPermission(methods.get("updateLocation"), PREFIX + "location:edit"); + assertPermission(methods.get("deleteLocation"), PREFIX + "location:delete"); + assertPermission(methods.get("pallets"), PREFIX + "pallet:list"); + assertPermission(methods.get("pallet"), PREFIX + "pallet:list"); + assertPermission(methods.get("createPallet"), PREFIX + "pallet:create"); + assertPermission(methods.get("updatePallet"), PREFIX + "pallet:edit"); + assertPermission(methods.get("deletePallet"), PREFIX + "pallet:delete"); + } + + private static void assertPermission(Method method, String authority) { + PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class); + Operation operation = method.getAnnotation(Operation.class); + assertEquals("hasAuthority('" + authority + "')", preAuthorize.value()); + assertEquals("Required authority: " + authority, operation.description()); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java index 778edcddc..e52b8fc79 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java @@ -67,6 +67,23 @@ class WarehouseResourceServiceTest { assertThat(captor.getValue().getWarehouseId()).isEqualTo(WAREHOUSE); } + @Test + void platformPalletUsesPublicScopeAgainstAnyExistingWarehouse() { + WarehouseEntity warehouse = warehouse(); + when(repository.findWarehouseById(WAREHOUSE)).thenReturn(warehouse); + WarehouseLocationEntity location = new WarehouseLocationEntity(); + location.setId(30001L); + when(repository.findLocation(0L, WAREHOUSE, 30001L)).thenReturn(location); + + service.createPallet(0L, WAREHOUSE, new PalletRequests.SaveRequest( + "PLT-PUBLIC", "STANDARD", null, BigDecimal.ONE, "IDLE", 30001L, null)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); + verify(repository).insertPallet(captor.capture()); + assertThat(captor.getValue().getCustomerId()).isZero(); + assertThat(captor.getValue().getWarehouseId()).isEqualTo(WAREHOUSE); + } + @Test void rejectsResourceAccessWhenWarehouseDoesNotBelongToTenant() { when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(false);