From 6acb6178da76c1ece340f0f7cd9f72cf6a3de56f Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 19 Aug 2026 16:24:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9A=82=E5=AD=98=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/nacos/overseas/README.md | 5 + config/nacos/overseas/audit-service.yml | 6 + config/nacos/overseas/baiyue-common.yml | 62 + .../nacos/overseas/event-bridge-service.yml | 20 + config/nacos/overseas/file-service.yml | 13 + config/nacos/overseas/gateway-overseas.yml | 3 + config/nacos/overseas/integration-service.yml | 12 + config/nacos/overseas/lastleg-service.yml | 6 + config/nacos/overseas/print-service.yml | 6 + .../overseas/warehouse-overseas-service.yml | 6 + event-bridge-service/AGENTS.md | 41 +- .../eventbridge/EventBridgeApplication.java | 5 + .../config/EventDispatcherProperties.java | 34 + .../controller/EventBridgeController.java | 25 +- .../domain/BridgeDeliveryAttempt.java | 24 + .../eventbridge/domain/BridgeInbox.java | 8 +- .../eventbridge/dto/EventReplayRequest.java | 4 + .../mapper/BridgeDeliveryAttemptMapper.java | 9 + .../eventbridge/mapper/BridgeInboxMapper.java | 52 + .../security/InternalServiceTokenGuard.java | 7 +- .../service/EventInboxService.java | 30 +- .../service/EventReplayService.java | 27 + .../service/HttpLastlegEventClient.java | 55 + .../service/LastlegEventClient.java | 7 + .../service/MybatisInboxStore.java | 5 +- .../service/OverseasEventDispatcher.java | 88 + .../src/main/resources/application-dev.yml | 6 - .../src/main/resources/application-test.yml | 6 - .../src/main/resources/application.yml | 38 +- .../src/main/resources/db/event-bridge.sql | 4 +- .../InternalServiceTokenGuardTest.java | 26 +- .../service/EventInboxServiceTest.java | 128 +- .../service/EventReplayServiceTest.java | 36 + .../service/OverseasEventDispatcherTest.java | 94 + gateway-overseas/AGENTS.md | 30 + gateway-overseas/pom.xml | 69 + .../BaiyueGatewayOverseasApplication.java | 14 + .../config/AppScopeAuthorizationManager.java | 32 + .../gateway/config/AudienceJwtValidator.java | 23 + .../gateway/config/GatewaySecurityConfig.java | 84 + .../config/GatewaySecurityProperties.java | 27 + .../config/RequiredClaimsJwtValidator.java | 26 + .../filter/TrustedIdentityHeaderFilter.java | 73 + .../main/resources/application-overseas.yml | 69 + .../src/main/resources/application.yml | 67 + .../AppScopeAuthorizationManagerTest.java | 55 + .../config/AudienceJwtValidatorTest.java | 33 + .../RequiredClaimsJwtValidatorTest.java | 36 + lastleg-service/AGENTS.md | 72 + lastleg-service/pom.xml | 23 + .../lastleg/BaiyueLastlegApplication.java | 14 + .../controller/LastlegHealthController.java | 34 + .../controller/LastlegOrderController.java | 113 + .../delivery/DeliveryMapperConfiguration.java | 9 + .../DeliveryTransferController.java | 74 + .../delivery/dto/DeliveryRequests.java | 23 + .../delivery/dto/DeliveryResponses.java | 27 + .../delivery/entity/DeliveryOrder.java | 30 + .../delivery/entity/OutboundOrder.java | 28 + .../delivery/entity/OutboundPalletItem.java | 27 + .../delivery/mapper/DeliveryOrderMapper.java | 17 + .../delivery/mapper/OutboundOrderMapper.java | 17 + .../mapper/OutboundPalletItemMapper.java | 14 + .../service/DeliveryTransferService.java | 213 + .../discard/DiscardMapperConfiguration.java | 9 + .../controller/CargoDiscardController.java | 84 + .../discard/dto/CargoDiscardRequests.java | 30 + .../discard/dto/CargoDiscardResponses.java | 30 + .../entity/CargoDiscardApplication.java | 34 + .../discard/entity/CargoDiscardCarton.java | 20 + .../discard/entity/CargoDiscardItem.java | 25 + .../entity/CargoDiscardStatusHistory.java | 21 + .../mapper/CargoDiscardApplicationMapper.java | 19 + .../mapper/CargoDiscardCartonMapper.java | 16 + .../mapper/CargoDiscardInventoryMapper.java | 69 + .../mapper/CargoDiscardItemMapper.java | 16 + .../CargoDiscardStatusHistoryMapper.java | 16 + .../model/CargoDiscardCandidateRow.java | 20 + ...argoDiscardInventoryAdjustmentService.java | 98 + .../discard/service/CargoDiscardService.java | 303 ++ .../dispatch/DispatchMapperConfiguration.java | 9 + .../controller/DispatchController.java | 134 + .../dispatch/dto/DispatchRequests.java | 35 + .../dispatch/dto/DispatchResponse.java | 22 + .../lastleg/dispatch/entity/DispatchItem.java | 38 + .../dispatch/entity/DispatchOrder.java | 40 + .../dispatch/entity/DispatchPalletLock.java | 29 + .../mapper/DispatchCandidateMapper.java | 78 + .../dispatch/mapper/DispatchItemMapper.java | 25 + .../dispatch/mapper/DispatchOrderMapper.java | 16 + .../mapper/DispatchPalletLockMapper.java | 15 + .../model/DispatchPalletCandidate.java | 37 + .../pda/DispatchPdaMapperConfiguration.java | 8 + .../pda/controller/DispatchPdaController.java | 69 + .../dispatch/pda/dto/DispatchPdaRequests.java | 26 + .../pda/dto/DispatchPdaResponses.java | 27 + .../pda/entity/DispatchPdaOperation.java | 23 + .../dispatch/pda/entity/DispatchPdaPick.java | 23 + .../pda/entity/DispatchPdaPickOrder.java | 22 + .../dispatch/pda/entity/DispatchPdaTask.java | 25 + .../mapper/DispatchPdaOperationMapper.java | 11 + .../pda/mapper/DispatchPdaPickMapper.java | 14 + .../mapper/DispatchPdaPickOrderMapper.java | 31 + .../pda/mapper/DispatchPdaTaskMapper.java | 14 + .../service/DispatchPdaWorkflowService.java | 277 ++ .../DispatchPickOrderAttributionService.java | 105 + .../dispatch/service/DispatchService.java | 261 ++ .../dropship/DropshipMapperConfiguration.java | 9 + .../controller/DropshipOrderController.java | 23 + .../dropship/dto/DropshipRequests.java | 15 + .../dropship/entity/DropshipCarton.java | 23 + .../dropship/entity/DropshipCartonLedger.java | 24 + .../dropship/entity/DropshipOrderProfile.java | 22 + .../dropship/entity/DropshipSkuInventory.java | 19 + .../mapper/DropshipCartonLedgerMapper.java | 8 + .../dropship/mapper/DropshipCartonMapper.java | 15 + .../mapper/DropshipOrderProfileMapper.java | 9 + .../mapper/DropshipSkuInventoryMapper.java | 13 + .../service/DropshipOrderService.java | 184 + .../dto/LastlegOrderDetailResponse.java | 27 + .../lastleg/dto/LastlegOrderRequests.java | 55 + .../lastleg/dto/LastlegOrderSummary.java | 51 + .../com/baiyue/lastleg/dto/package-info.java | 5 + .../lastleg/entity/LastlegEventInbox.java | 3 + .../baiyue/lastleg/entity/LastlegOrder.java | 64 + .../lastleg/entity/LastlegOrderDetail.java | 3 + .../entity/LastlegOrderStatusHistory.java | 3 + .../baiyue/lastleg/entity/package-info.java | 5 + .../InboundPlanMapperConfiguration.java | 9 + .../controller/InboundPlanController.java | 74 + .../dto/BatchFbaAppointmentRequest.java | 19 + .../dto/ConvertInboundPlanRequest.java | 9 + .../dto/FbaAppointmentResponse.java | 23 + .../inboundplan/dto/InboundPlanResponse.java | 37 + .../entity/InboundFbaAppointment.java | 25 + .../entity/InboundFbaAppointmentItem.java | 22 + .../inboundplan/entity/InboundPlan.java | 25 + .../inboundplan/entity/InboundPlanLine.java | 25 + .../inboundplan/entity/InboundPlanPallet.java | 31 + .../entity/InboundPlanPalletAllocation.java | 25 + .../InboundFbaAppointmentItemMapper.java | 9 + .../mapper/InboundFbaAppointmentMapper.java | 9 + .../InboundFbaAppointmentQueryMapper.java | 22 + .../mapper/InboundPlanLineMapper.java | 9 + .../inboundplan/mapper/InboundPlanMapper.java | 13 + .../mapper/InboundPlanOrderMapper.java | 50 + .../InboundPlanPalletAllocationMapper.java | 9 + .../mapper/InboundPlanPalletMapper.java | 9 + .../model/FbaAppointmentLineSnapshot.java | 10 + .../model/FbaAppointmentStatus.java | 8 + .../model/SourceOrderSnapshot.java | 30 + .../InboundPalletAssignmentController.java | 43 + .../pda/controller/InboundPdaController.java | 107 + .../inboundplan/pda/dto/PdaRequests.java | 19 + .../inboundplan/pda/dto/PdaResponses.java | 26 + .../pda/entity/InboundPalletMerge.java | 32 + .../pda/entity/InboundPdaOperation.java | 24 + .../pda/entity/InboundPdaTask.java | 27 + .../pda/entity/InboundStockReceipt.java | 34 + .../gateway/HttpWarehouseResourceGateway.java | 89 + .../pda/gateway/WarehouseResourceGateway.java | 11 + .../pda/mapper/InboundPalletMergeMapper.java | 9 + .../pda/mapper/InboundPdaOperationMapper.java | 13 + .../pda/mapper/InboundPdaTaskMapper.java | 16 + .../pda/mapper/InboundStockReceiptMapper.java | 14 + .../pda/repository/PdaWorkflowRepository.java | 79 + .../service/InboundPdaWorkflowService.java | 532 +++ .../service/InboundStockReceiptService.java | 193 + .../service/InboundFbaAppointmentService.java | 180 + .../service/InboundPlanService.java | 243 ++ .../inboundplan/service/PalletAllocator.java | 129 + .../InventoryMapperConfiguration.java | 9 + .../InventoryCommandMapperConfiguration.java | 9 + .../InventoryCommandController.java | 106 + .../command/dto/InventoryCommandRequests.java | 60 + .../command/dto/InventoryCommandResponse.java | 19 + .../mapper/InventoryCommandMapper.java | 22 + .../service/InventoryCommandService.java | 453 +++ .../inventory/entity/InventoryBalance.java | 37 + .../inventory/entity/InventoryLedger.java | 50 + .../inventory/entity/InventoryOperation.java | 25 + .../inventory/entity/InventoryPosition.java | 34 + .../mapper/InventoryBalanceMapper.java | 52 + .../mapper/InventoryLedgerMapper.java | 33 + .../mapper/InventoryOperationMapper.java | 16 + .../mapper/InventoryPositionMapper.java | 27 + .../inventory/model/InventorySummary.java | 15 + .../InventoryQueryMapperConfiguration.java | 9 + .../controller/InventoryQueryController.java | 112 + .../query/dto/InventoryBalanceView.java | 28 + .../query/dto/InventoryDashboardResponse.java | 15 + .../query/dto/InventoryLedgerView.java | 28 + .../query/dto/InventoryPositionView.java | 28 + .../query/dto/InventoryQueryRequests.java | 53 + .../query/mapper/InventoryQueryMapper.java | 119 + .../model/InventoryAggregateProjection.java | 15 + .../query/service/InventoryQueryService.java | 182 + .../mapper/LastlegEventInboxMapper.java | 3 + .../mapper/LastlegOrderDetailMapper.java | 3 + .../lastleg/mapper/LastlegOrderMapper.java | 38 + .../LastlegOrderStatusHistoryMapper.java | 3 + .../baiyue/lastleg/mapper/package-info.java | 5 + .../ProcurementMapperConfiguration.java | 9 + ...MerchantOverseasProcurementController.java | 56 + ...PlatformOverseasProcurementController.java | 96 + .../dto/OverseasProcurementRequests.java | 63 + .../dto/OverseasProcurementResponses.java | 34 + .../entity/OverseasProcurementOrder.java | 48 + .../entity/OverseasProcurementOutbound.java | 24 + .../OverseasProcurementStatusHistory.java | 21 + .../OverseasProcurementOrderMapper.java | 25 + .../OverseasProcurementOutboundMapper.java | 16 + ...verseasProcurementStatusHistoryMapper.java | 16 + .../service/OverseasProcurementService.java | 293 ++ .../relabel/RelabelMapperConfiguration.java | 9 + .../RelabelApplicationController.java | 53 + .../lastleg/relabel/dto/RelabelRequests.java | 54 + .../lastleg/relabel/dto/RelabelResponses.java | 22 + .../relabel/entity/RelabelApplication.java | 32 + .../lastleg/relabel/entity/RelabelItem.java | 31 + .../relabel/entity/RelabelStatusHistory.java | 21 + .../mapper/RelabelApplicationMapper.java | 19 + .../relabel/mapper/RelabelItemMapper.java | 9 + .../mapper/RelabelStatusHistoryMapper.java | 9 + .../service/RelabelApplicationService.java | 264 ++ .../lastleg/repository/package-info.java | 5 + .../security/InternalServiceTokenGuard.java | 20 + .../service/LastlegOrderNoGenerator.java | 35 + .../lastleg/service/LastlegOrderService.java | 202 + .../service/LoadingCompletedConsumer.java | 203 + .../baiyue/lastleg/service/package-info.java | 5 + .../transfer/TransferMapperConfiguration.java | 4 + .../WarehouseTransferController.java | 93 + .../dto/WarehouseTransferRequests.java | 76 + .../dto/WarehouseTransferResponses.java | 21 + .../WarehouseTransferInventoryAllocation.java | 30 + .../entity/WarehouseTransferItem.java | 20 + .../entity/WarehouseTransferOrder.java | 43 + .../entity/WarehouseTransferReceipt.java | 27 + .../entity/WarehouseTransferSourceOrder.java | 20 + .../mapper/InboundSnapshotMapper.java | 10 + ...ouseTransferInventoryAllocationMapper.java | 21 + .../mapper/WarehouseTransferItemMapper.java | 5 + .../mapper/WarehouseTransferOrderMapper.java | 14 + .../WarehouseTransferReceiptMapper.java | 16 + .../WarehouseTransferSourceOrderMapper.java | 5 + .../service/WarehouseTransferService.java | 536 +++ .../trucking/TruckingMapperConfiguration.java | 9 + .../MerchantUsTruckOrderController.java | 62 + .../PlatformUsTruckOrderController.java | 83 + .../trucking/dto/UsTruckOrderRequests.java | 89 + .../trucking/dto/UsTruckOrderResponses.java | 28 + .../lastleg/trucking/entity/UsTruckOrder.java | 46 + .../trucking/entity/UsTruckOrderPallet.java | 25 + .../entity/UsTruckOrderStatusHistory.java | 21 + .../trucking/mapper/UsTruckOrderMapper.java | 19 + .../mapper/UsTruckOrderPalletMapper.java | 15 + .../UsTruckOrderStatusHistoryMapper.java | 16 + .../trucking/service/UsTruckOrderService.java | 278 ++ .../src/main/resources/application.yml | 44 + .../src/main/resources/db/lastleg.sql | 1139 ++++++ .../LastlegOrderControllerTest.java | 54 + .../delivery/DeliverySchemaContractTest.java | 58 + .../service/DeliveryTransferServiceTest.java | 297 ++ .../CargoDiscardMysqlAcceptanceTest.java | 164 + .../CargoDiscardSchemaContractTest.java | 22 + .../CargoDiscardControllerTest.java | 50 + ...DiscardInventoryAdjustmentServiceTest.java | 108 + .../service/CargoDiscardServiceTest.java | 180 + .../dispatch/DispatchSchemaContractTest.java | 33 + .../DispatchPdaWorkflowServiceTest.java | 121 + ...spatchPickOrderAttributionServiceTest.java | 102 + .../dispatch/service/DispatchServiceTest.java | 112 + .../dropship/DropshipSchemaContractTest.java | 35 + .../service/DropshipOrderServiceTest.java | 148 + .../InboundPlanSchemaContractTest.java | 32 + .../InboundPdaWorkflowServiceTest.java | 374 ++ .../InboundStockReceiptServiceTest.java | 214 + .../InboundFbaAppointmentServiceTest.java | 137 + .../service/InboundPlanServiceTest.java | 143 + .../service/PalletAllocatorTest.java | 57 + .../InventorySchemaContractTest.java | 121 + .../command/InventoryCommandServiceTest.java | 165 + .../InventoryCommandControllerTest.java | 72 + .../InventoryQueryControllerTest.java | 58 + .../service/InventoryQueryServiceTest.java | 131 + ...rseasProcurementInventoryBoundaryTest.java | 25 + ...OverseasProcurementSchemaContractTest.java | 21 + .../OverseasProcurementControllerTest.java | 68 + .../OverseasProcurementServiceTest.java | 103 + .../lastleg/relabel/RelabelContractTest.java | 40 + .../RelabelApplicationServiceTest.java | 259 ++ .../SchemaChineseCommentContractTest.java | 140 + .../InternalServiceTokenGuardTest.java | 12 + .../service/LastlegOrderNoGeneratorTest.java | 31 + .../service/LastlegOrderServiceTest.java | 144 + .../service/LoadingCompletedConsumerTest.java | 13 + .../WarehouseTransferSchemaContractTest.java | 25 + .../service/WarehouseTransferServiceTest.java | 371 ++ .../UsTruckOrderSchemaContractTest.java | 25 + .../UsTruckOrderControllerTest.java | 57 + .../service/UsTruckOrderServiceTest.java | 142 + pom.xml | 3 + scripts/db/incremental.sql | 3553 +++++++++++++++++ warehouse-overseas-service/AGENTS.md | 45 + warehouse-overseas-service/pom.xml | 75 + .../BaiyueWarehouseOverseasApplication.java | 14 + .../WarehouseDatabaseGuardConfiguration.java | 27 + .../InternalWarehousePalletController.java | 25 + ...ternalWarehouseResourceLockController.java | 70 + .../MerchantWarehouseController.java | 67 + .../MerchantWarehouseResourceController.java | 109 + .../PlatformWarehouseController.java | 166 + .../PlatformWarehouseResourceController.java | 131 + .../controller/WarehouseHealthController.java | 26 + .../warehouse/dto/DropshipPalletRequests.java | 9 + .../warehouse/dto/LocationRequests.java | 39 + .../baiyue/warehouse/dto/PalletRequests.java | 45 + .../dto/WarehouseDetailResponse.java | 19 + .../dto/WarehousePalletScrapResponse.java | 4 + .../warehouse/dto/WarehouseRequests.java | 117 + .../dto/WarehouseResourceLockRequests.java | 25 + .../dto/WarehouseResourceLockResponse.java | 25 + .../entity/ServiceCapabilityEntity.java | 57 + .../entity/WarehouseAddressEntity.java | 77 + .../warehouse/entity/WarehouseEntity.java | 71 + .../entity/WarehouseLocationEntity.java | 26 + .../entity/WarehousePalletBindingEntity.java | 19 + .../entity/WarehousePalletEntity.java | 27 + .../entity/WarehouseResourceLockEntity.java | 29 + .../mapper/ServiceCapabilityMapper.java | 16 + .../mapper/WarehouseAddressMapper.java | 16 + .../mapper/WarehouseLocationMapper.java | 22 + .../warehouse/mapper/WarehouseMapper.java | 6 + .../mapper/WarehousePalletBindingMapper.java | 13 + .../mapper/WarehousePalletMapper.java | 7 + .../mapper/WarehouseResourceLockMapper.java | 100 + .../repository/WarehouseRepository.java | 112 + .../WarehouseResourceLockRepository.java | 90 + .../WarehouseResourceRepository.java | 158 + .../security/InternalServiceTokenGuard.java | 26 + .../service/WarehouseApplicationService.java | 176 + .../service/WarehouseResourceLockService.java | 158 + .../service/WarehouseResourceService.java | 282 ++ .../src/main/resources/application.yml | 45 + .../src/main/resources/db/warehouse.sql | 259 ++ ...rehouseDatabaseGuardConfigurationTest.java | 29 + .../WarehouseResourceControllerTest.java | 69 + .../SchemaChineseCommentContractTest.java | 98 + .../WarehouseResourceLockSchemaTest.java | 24 + .../InternalServiceTokenGuardTest.java | 32 + .../WarehouseApplicationServiceTest.java | 59 + .../WarehouseResourceLockServiceTest.java | 263 ++ .../service/WarehouseResourceServiceTest.java | 260 ++ 354 files changed, 25000 insertions(+), 198 deletions(-) create mode 100644 config/nacos/overseas/README.md create mode 100644 config/nacos/overseas/audit-service.yml create mode 100644 config/nacos/overseas/baiyue-common.yml create mode 100644 config/nacos/overseas/event-bridge-service.yml create mode 100644 config/nacos/overseas/file-service.yml create mode 100644 config/nacos/overseas/gateway-overseas.yml create mode 100644 config/nacos/overseas/integration-service.yml create mode 100644 config/nacos/overseas/lastleg-service.yml create mode 100644 config/nacos/overseas/print-service.yml create mode 100644 config/nacos/overseas/warehouse-overseas-service.yml create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/config/EventDispatcherProperties.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeDeliveryAttempt.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReplayRequest.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeDeliveryAttemptMapper.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/LastlegEventClient.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java delete mode 100644 event-bridge-service/src/main/resources/application-dev.yml delete mode 100644 event-bridge-service/src/main/resources/application-test.yml create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java create mode 100644 gateway-overseas/AGENTS.md create mode 100644 gateway-overseas/pom.xml create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/BaiyueGatewayOverseasApplication.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/config/AudienceJwtValidator.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java create mode 100644 gateway-overseas/src/main/resources/application-overseas.yml create mode 100644 gateway-overseas/src/main/resources/application.yml create mode 100644 gateway-overseas/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java create mode 100644 gateway-overseas/src/test/java/com/baiyue/gateway/config/AudienceJwtValidatorTest.java create mode 100644 gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java create mode 100644 lastleg-service/AGENTS.md create mode 100644 lastleg-service/pom.xml create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/DeliveryMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPalletItemMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/DiscardMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardCarton.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardStatusHistory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardCartonMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardItemMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardStatusHistoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardCandidateRow.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/DispatchMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/DispatchPdaMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaOperation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaOperationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/DropshipMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/controller/DropshipOrderController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCarton.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCartonLedger.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipOrderProfile.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipSkuInventory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonLedgerMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dto/package-info.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventInbox.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderDetail.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderStatusHistory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/entity/package-info.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/InboundPlanMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/BatchFbaAppointmentRequest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointmentItem.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPalletAllocation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentItemMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanLineMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletAllocationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentLineSnapshot.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentStatus.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPalletMerge.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaOperation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPalletMergeMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaOperationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaTaskMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundStockReceiptMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/PalletAllocator.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/InventoryMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/InventoryCommandMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandResponse.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryBalance.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryOperation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryPosition.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryOperationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/model/InventorySummary.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/InventoryQueryMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryBalanceView.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryDashboardResponse.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPositionView.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/model/InventoryAggregateProjection.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventInboxMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderDetailMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderStatusHistoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/package-info.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/ProcurementMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOutbound.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementStatusHistory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOrderMapper.java create 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/procurement/mapper/OverseasProcurementStatusHistoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/RelabelMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelItem.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelStatusHistory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelStatusHistoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/repository/package-info.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/service/package-info.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/TransferMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferInventoryAllocation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferItem.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferReceipt.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferSourceOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferInventoryAllocationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferItemMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferReceiptMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferSourceOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/TruckingMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderPallet.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderPalletMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderStatusHistoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java create mode 100644 lastleg-service/src/main/resources/application.yml create mode 100644 lastleg-service/src/main/resources/db/lastleg.sql create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardMysqlAcceptanceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardSchemaContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchSchemaContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dropship/DropshipSchemaContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/PalletAllocatorTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferSchemaContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java create mode 100644 scripts/db/incremental.sql create mode 100644 warehouse-overseas-service/AGENTS.md create mode 100644 warehouse-overseas-service/pom.xml create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/BaiyueWarehouseOverseasApplication.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/DropshipPalletRequests.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/LocationRequests.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseDetailResponse.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletScrapResponse.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockRequests.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockResponse.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/ServiceCapabilityEntity.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseAddressEntity.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseLocationEntity.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBindingEntity.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletEntity.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseResourceLockEntity.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/ServiceCapabilityMapper.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseAddressMapper.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseMapper.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBindingMapper.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/InternalServiceTokenGuard.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java create mode 100644 warehouse-overseas-service/src/main/resources/application.yml create mode 100644 warehouse-overseas-service/src/main/resources/db/warehouse.sql create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehouseResourceLockSchemaTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/security/InternalServiceTokenGuardTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceLockServiceTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java diff --git a/config/nacos/overseas/README.md b/config/nacos/overseas/README.md new file mode 100644 index 000000000..95d89c508 --- /dev/null +++ b/config/nacos/overseas/README.md @@ -0,0 +1,5 @@ +# 海外 Nacos 配置 + +将本目录文件按原文件名作为 Data ID 发布到 `prod-overseas` 命名空间和 `BAIYUE` 分组。配置只引用环境变量,不保存密码或访问密钥。Gateway 必须同时启用 `overseas` Profile。 + +海外入口与仓储 Data ID 分别为 `gateway-overseas.yml`、`warehouse-overseas-service.yml`。`print-service.yml` 对应头程仓库保留的共用打印服务,不包含尾程旧 Print 模块。 diff --git a/config/nacos/overseas/audit-service.yml b/config/nacos/overseas/audit-service.yml new file mode 100644 index 000000000..1ac49f251 --- /dev/null +++ b/config/nacos/overseas/audit-service.yml @@ -0,0 +1,6 @@ +spring: + datasource: + url: ${AUDIT_DB_URL} + username: ${AUDIT_DB_USERNAME} + password: ${AUDIT_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/config/nacos/overseas/baiyue-common.yml b/config/nacos/overseas/baiyue-common.yml new file mode 100644 index 000000000..423373321 --- /dev/null +++ b/config/nacos/overseas/baiyue-common.yml @@ -0,0 +1,62 @@ +baiyue: + deployment: + region-code: ${REGION_CODE:OVERSEAS} + cell: ${DEPLOYMENT_CELL:prod-overseas} + data-residency: ${DATA_RESIDENCY:OVERSEAS} + security: + issuer: ${JWT_ISSUER:https://auth.example.com} + audience: ${JWT_AUDIENCE:lastleg-api} + jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} + openapi.enabled: ${SWAGGER_ENABLED:false} + audit.enabled: true +spring: + jackson: + default-property-inclusion: non_null + deserialization: + fail-on-unknown-properties: false + serialization: + write-dates-as-timestamps: false + data: + redis: + host: ${REDIS_HOST:redis-overseas.internal} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + database: ${REDIS_DATABASE:0} + timeout: ${REDIS_TIMEOUT:3s} + cloud: + nacos: + discovery: + group: ${NACOS_GROUP:BAIYUE} + namespace: ${NACOS_NAMESPACE:prod-overseas} + openfeign: + circuitbreaker.enabled: true + client.config.default: + connect-timeout: 3000 + read-timeout: 5000 +server: + shutdown: graceful +management: + endpoints: + web: + exposure: + include: health,info,prometheus + endpoint: + health: + probes.enabled: true + show-details: never +mybatis-plus: + configuration: + map-underscore-to-camel-case: true + call-setters-on-nulls: true + global-config: + banner: false + db-config: + id-type: assign_id + logic-delete-field: deleted + logic-delete-value: 1 + logic-not-delete-value: 0 +springdoc: + api-docs.enabled: ${SWAGGER_ENABLED:false} + swagger-ui.enabled: ${SWAGGER_ENABLED:false} diff --git a/config/nacos/overseas/event-bridge-service.yml b/config/nacos/overseas/event-bridge-service.yml new file mode 100644 index 000000000..9d911281c --- /dev/null +++ b/config/nacos/overseas/event-bridge-service.yml @@ -0,0 +1,20 @@ +spring: + datasource: + url: ${EVENT_BRIDGE_DB_URL} + username: ${EVENT_BRIDGE_DB_USERNAME} + password: ${EVENT_BRIDGE_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver +baiyue: + region: ${REGION_CODE:OVERSEAS} + 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} + fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3s} + batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} + max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} + lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s} + initial-backoff: ${EVENT_DISPATCH_INITIAL_BACKOFF:5s} + max-backoff: ${EVENT_DISPATCH_MAX_BACKOFF:15m} + connect-timeout: ${EVENT_DISPATCH_CONNECT_TIMEOUT:3s} + request-timeout: ${EVENT_DISPATCH_REQUEST_TIMEOUT:10s} diff --git a/config/nacos/overseas/file-service.yml b/config/nacos/overseas/file-service.yml new file mode 100644 index 000000000..971474618 --- /dev/null +++ b/config/nacos/overseas/file-service.yml @@ -0,0 +1,13 @@ +spring: + datasource: + url: ${FILE_DB_URL} + username: ${FILE_DB_USERNAME} + password: ${FILE_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver +file: + storage: + provider: ${FILE_STORAGE_PROVIDER:s3} + endpoint: ${FILE_STORAGE_ENDPOINT:} + bucket: ${FILE_STORAGE_BUCKET:} + access-key: ${FILE_STORAGE_ACCESS_KEY:} + secret-key: ${FILE_STORAGE_SECRET_KEY:} diff --git a/config/nacos/overseas/gateway-overseas.yml b/config/nacos/overseas/gateway-overseas.yml new file mode 100644 index 000000000..e16f76e10 --- /dev/null +++ b/config/nacos/overseas/gateway-overseas.yml @@ -0,0 +1,3 @@ +springdoc: + api-docs.enabled: ${SWAGGER_ENABLED:false} + swagger-ui.enabled: ${SWAGGER_ENABLED:false} diff --git a/config/nacos/overseas/integration-service.yml b/config/nacos/overseas/integration-service.yml new file mode 100644 index 000000000..25617fae1 --- /dev/null +++ b/config/nacos/overseas/integration-service.yml @@ -0,0 +1,12 @@ +baiyue: + integration: + baidu-ocr: + enabled: ${BAIDU_OCR_ENABLED:false} + amazon-sp-api: + enabled: ${AMAZON_SP_API_ENABLED:true} + client-id: ${AMAZON_SP_API_CLIENT_ID:} + client-secret: ${AMAZON_SP_API_CLIENT_SECRET:} + refresh-token: ${AMAZON_SP_API_REFRESH_TOKEN:} + aws-access-key: ${AMAZON_SP_API_AWS_ACCESS_KEY:} + aws-secret-key: ${AMAZON_SP_API_AWS_SECRET_KEY:} + region: ${AMAZON_SP_API_REGION:} diff --git a/config/nacos/overseas/lastleg-service.yml b/config/nacos/overseas/lastleg-service.yml new file mode 100644 index 000000000..1b88f047a --- /dev/null +++ b/config/nacos/overseas/lastleg-service.yml @@ -0,0 +1,6 @@ +spring: + datasource: + url: ${LASTLEG_DB_URL} + username: ${LASTLEG_DB_USERNAME} + password: ${LASTLEG_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/config/nacos/overseas/print-service.yml b/config/nacos/overseas/print-service.yml new file mode 100644 index 000000000..613433e3f --- /dev/null +++ b/config/nacos/overseas/print-service.yml @@ -0,0 +1,6 @@ +spring: + datasource: + url: ${PRINT_DB_URL} + username: ${PRINT_DB_USERNAME} + password: ${PRINT_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/config/nacos/overseas/warehouse-overseas-service.yml b/config/nacos/overseas/warehouse-overseas-service.yml new file mode 100644 index 000000000..a05d57b33 --- /dev/null +++ b/config/nacos/overseas/warehouse-overseas-service.yml @@ -0,0 +1,6 @@ +spring: + datasource: + url: ${WAREHOUSE_DB_URL} + username: ${WAREHOUSE_DB_USERNAME} + password: ${WAREHOUSE_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index 59c29eeec..7e2e6672b 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -2,33 +2,32 @@ ## 职责边界 -- 负责跨区域事件接收、内部令牌保护、契约校验和 Inbox 幂等持久化。 -- 事件模型来自 `common-event-contract/`;本服务不共享国内/国外业务数据库,也不承载头程或尾程领域逻辑。 -- 真实跨区发送、重试调度和死信补投仍需按需求继续完善,不把当前接收骨架描述为完整消息平台。 +- 负责跨区事件接收、Inbox 持久化、向海外尾程投递、失败重试、投递记录和人工补投。 +- 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。 +- 事件结构和 Payload 哈希以 `common-event-contract` 为准。 ## 快速索引 | 功能 | 入口 | |---|---| -| 应用入口 | `src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java` | -| 事件接收与状态 API | `controller/EventBridgeController.java`(`/internal/event-bridge`) | -| 接收结果与状态 DTO | `dto/EventReceiveResult.java`、`BridgeStatus.java` | -| 校验与 Inbox 编排 | `service/EventInboxService.java` | -| Inbox 抽象与 MyBatis 实现 | `service/InboxStore.java`、`MybatisInboxStore.java` | -| Inbox 数据模型 | `domain/BridgeInbox.java`、`mapper/BridgeInboxMapper.java` | +| 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` | +| Inbox 接收与幂等 | `service/EventInboxService.java` | +| 海外调度与重试 | `service/OverseasEventDispatcher.java` | +| 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` | +| 人工补投 | `service/EventReplayService.java` | +| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | -| 国内装柜完成生产链 | `../firstleg/service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`client/EventBridgeClient.java`;Payload 为 `../common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java` | -| 表结构 | `src/main/resources/db/event-bridge.sql` | -| 测试 | `src/test/java/com/baiyue/eventbridge/security/`、`service/` | -| 架构边界 | `../AGENTS.md` 的仓库边界、`../docs/头程项目目录结构与服务说明.md` 的主要调用关系与开发拆分约束 | +| 数据库 | `src/main/resources/db/event-bridge.sql` | +| 测试 | `src/test/java/com/baiyue/eventbridge/` | + +## 可靠投递规则 + +- `RECEIVED` 和到期的 `RETRY` 都必须可被调度;只有尾程确认成功后才能标记 `PROCESSED`。 +- 每次投递保存 attempt、错误和时间;达到上限进入 `DEAD`,经授权人工补投后重新进入重试队列。 +- 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。 +- 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。 ## 修改与验证 -- 先使用 `common-event-contract` 校验事件 ID、区域、版本、业务键、时间与 Payload 哈希,再执行幂等入库。 -- 重复事件应返回稳定结果且不重复产生副作用;数据库唯一约束与应用幂等逻辑要同步。 -- 跨区域接口必须鉴权、限流并最小化载荷,禁止携带密钥或非必要客户敏感数据。 -- Event Bridge 内部令牌失败和接收基础设施不可用属于统一 HTTP 200 业务契约的明确例外,保留真实 401/503;已经通过 Controller 进入公共异常处理的普通业务失败才使用 HTTP 200 + body code。 -- 本服务只扫描 `com.baiyue.eventbridge`,统一异常和 Correlation 能力依赖 `common-web/CommonWebAutoConfiguration` 自动装配;调整公共 Web 配置后追加本模块上下文测试。 -- 契约变化按 `../common-event-contract/AGENTS.md` 直接同步当前模型、国内生产者和国外消费者,不保留旧事件兼容分支。 -- Event Bridge 只负责事件接收、校验和 Inbox 幂等,不负责用户登录或 JWT 转发;MQ 投递链路的服务身份与用户 Token 分离。 -- 从父目录运行 `mvn -pl event-bridge-service -am test`。 +- 定向验证:`mvn -pl event-bridge-service -am test`。 +- 重试相关修改至少覆盖 `RECEIVED`、`RETRY`、成功、失败、租约、最大次数和人工补投。 diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java index dd0e0b068..a79fbbeb9 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java @@ -1,14 +1,19 @@ package com.baiyue.eventbridge; +import com.baiyue.eventbridge.config.EventDispatcherProperties; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.scheduling.annotation.EnableScheduling; /** * 跨区域事件桥启动入口。 */ @MapperScan("com.baiyue.eventbridge.mapper") @SpringBootApplication +@EnableScheduling +@EnableConfigurationProperties(EventDispatcherProperties.class) public class EventBridgeApplication { public static void main(String[] args) { SpringApplication.run(EventBridgeApplication.class, args); diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/config/EventDispatcherProperties.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/config/EventDispatcherProperties.java new file mode 100644 index 000000000..fffbbea19 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/config/EventDispatcherProperties.java @@ -0,0 +1,34 @@ +package com.baiyue.eventbridge.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.net.URI; +import java.time.Duration; + +@ConfigurationProperties(prefix = "baiyue.event-dispatcher") +public record EventDispatcherProperties( + URI consumerUrl, + int batchSize, + int maxAttempts, + Duration leaseDuration, + Duration initialBackoff, + Duration maxBackoff, + Duration connectTimeout, + Duration requestTimeout) { + + public EventDispatcherProperties { + consumerUrl = consumerUrl == null + ? URI.create("http://127.0.0.1:9303/internal/lastleg/events") : consumerUrl; + batchSize = batchSize <= 0 ? 50 : batchSize; + maxAttempts = maxAttempts <= 0 ? 10 : maxAttempts; + leaseDuration = positiveOrDefault(leaseDuration, Duration.ofSeconds(30)); + initialBackoff = positiveOrDefault(initialBackoff, Duration.ofSeconds(5)); + maxBackoff = positiveOrDefault(maxBackoff, Duration.ofMinutes(15)); + connectTimeout = positiveOrDefault(connectTimeout, Duration.ofSeconds(3)); + requestTimeout = positiveOrDefault(requestTimeout, Duration.ofSeconds(10)); + } + + private static Duration positiveOrDefault(Duration value, Duration fallback) { + return value == null || value.isNegative() || value.isZero() ? fallback : value; + } +} 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 af0707c23..e26f92210 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 @@ -4,7 +4,9 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.event.EventEnvelope; import com.baiyue.eventbridge.dto.BridgeStatus; import com.baiyue.eventbridge.dto.EventReceiveResult; +import com.baiyue.eventbridge.dto.EventReplayRequest; import com.baiyue.eventbridge.service.EventInboxService; +import com.baiyue.eventbridge.service.EventReplayService; import com.baiyue.eventbridge.security.InternalServiceTokenGuard; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -18,6 +20,7 @@ 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 org.springframework.web.bind.annotation.PathVariable; /** * 事件桥内部接口。生产环境必须在网关或基础设施层增加 mTLS 与来源白名单。 @@ -26,13 +29,11 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/internal/event-bridge") public class EventBridgeController { - @Resource - private EventInboxService eventInboxService; + @Resource private EventInboxService eventInboxService; + @Resource private EventReplayService eventReplayService; + @Resource private InternalServiceTokenGuard internalServiceTokenGuard; - @Resource - private InternalServiceTokenGuard internalServiceTokenGuard; - - @Value("${baiyue.region:CN}") + @Value("${baiyue.region:OVERSEAS}") private String region; @Operation(summary = "幂等接收事件", description = "当前仅完成数据库接收骨架,不代表生产跨区传输和 mTLS 已开通") @@ -50,6 +51,18 @@ public class EventBridgeController { return ApiResponse.success(eventInboxService.receive(envelope)); } + @Operation(summary = "人工补投死信事件") + @PostMapping("/events/{eventId}/replay") + public ApiResponse replay( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("X-Operator-Id") String operatorId, + @PathVariable String eventId, + @RequestBody EventReplayRequest request) { + internalServiceTokenGuard.verify(token); + eventReplayService.replayDead(eventId, operatorId, request.reason()); + return ApiResponse.success(null); + } + @Operation(summary = "查询事件桥能力状态") @ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功")) @GetMapping("/status") diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeDeliveryAttempt.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeDeliveryAttempt.java new file mode 100644 index 000000000..eaf57c555 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeDeliveryAttempt.java @@ -0,0 +1,24 @@ +package com.baiyue.eventbridge.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("bridge_delivery_attempt") +public class BridgeDeliveryAttempt { + @TableId(type = IdType.AUTO) + private Long id; + private String eventId; + private String sourceRegion; + private String targetRegion; + private Integer attemptNo; + private String status; + private String responseCode; + private String errorMessage; + private LocalDateTime startedAt; + private LocalDateTime finishedAt; +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java index 4094be14e..96f15d531 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java @@ -20,7 +20,7 @@ public class BridgeInbox { private Integer eventVersion; private String sourceRegion; private String targetRegion; - private String tenantId; + private String customerId; private String aggregateType; private String aggregateId; private Long aggregateVersion; @@ -32,5 +32,11 @@ public class BridgeInbox { private String payloadHash; private String payloadJson; private String status; + private String leaseOwner; + private LocalDateTime leaseUntil; + private Integer retryCount; + private LocalDateTime nextRetryAt; + private String lastError; private LocalDateTime receivedAt; + private LocalDateTime processedAt; } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReplayRequest.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReplayRequest.java new file mode 100644 index 000000000..25c4bb324 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReplayRequest.java @@ -0,0 +1,4 @@ +package com.baiyue.eventbridge.dto; + +public record EventReplayRequest(String reason) { +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeDeliveryAttemptMapper.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeDeliveryAttemptMapper.java new file mode 100644 index 000000000..755341428 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeDeliveryAttemptMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.eventbridge.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface BridgeDeliveryAttemptMapper extends BaseMapper { +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java index 5ec53602f..f93a34ff2 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java @@ -3,10 +3,62 @@ package com.baiyue.eventbridge.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.eventbridge.domain.BridgeInbox; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; /** * 入站事件持久化映射。 */ @Mapper public interface BridgeInboxMapper extends BaseMapper { + @Select(""" + SELECT event_id FROM bridge_inbox + WHERE target_region = 'OVERSEAS' + AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3))) + OR (status = 'PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3))) + ORDER BY received_at LIMIT #{limit} + """) + List dispatchCandidates(@Param("limit") int limit); + + @Update(""" + UPDATE bridge_inbox SET status='PROCESSING', lease_owner=#{owner}, + lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND) + WHERE event_id=#{eventId} AND target_region='OVERSEAS' + AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3))) + OR (status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3))) + """) + int claim(@Param("eventId") String eventId, @Param("owner") String owner, + @Param("leaseSeconds") long leaseSeconds); + + @Update(""" + UPDATE bridge_inbox SET status='PROCESSED', processed_at=CURRENT_TIMESTAMP(3), + lease_owner=NULL, lease_until=NULL, next_retry_at=NULL, last_error=NULL + WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner} + """) + int markProcessed(@Param("eventId") String eventId, @Param("owner") String owner); + + @Update(""" + UPDATE bridge_inbox SET retry_count=retry_count+1, + status=CASE WHEN retry_count+1 >= #{maxAttempts} THEN 'DEAD' ELSE 'RETRY' END, + next_retry_at=CASE WHEN retry_count+1 >= #{maxAttempts} THEN NULL + ELSE DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{backoffSeconds} SECOND) END, + lease_owner=NULL, lease_until=NULL, last_error=#{error} + WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner} + """) + int markFailed(@Param("eventId") String eventId, @Param("owner") String owner, + @Param("maxAttempts") int maxAttempts, + @Param("backoffSeconds") long backoffSeconds, @Param("error") String error); + + @Select("SELECT COALESCE(MAX(attempt_no), 0) + 1 FROM bridge_delivery_attempt WHERE event_id=#{eventId}") + int nextAttemptNo(@Param("eventId") String eventId); + + @Update(""" + UPDATE bridge_inbox SET status='RETRY', retry_count=0, next_retry_at=CURRENT_TIMESTAMP(3), + lease_owner=NULL, lease_until=NULL, last_error=#{reason} + WHERE event_id=#{eventId} AND status='DEAD' + """) + int replayDead(@Param("eventId") String eventId, @Param("reason") String reason); } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java index 84f9ee1ab..ba4717409 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java @@ -13,8 +13,11 @@ import java.security.MessageDigest; */ @Component public class InternalServiceTokenGuard { - @Value("${baiyue.security.internal-service-token:}") - private String expectedToken; + private final String expectedToken; + + public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token:}") String expectedToken) { + this.expectedToken = expectedToken; + } public void verify(String actualToken) { if (expectedToken == null || expectedToken.isBlank()) { 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 47042bb03..146bb4a5c 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 @@ -1,7 +1,5 @@ package com.baiyue.eventbridge.service; -import jakarta.annotation.Resource; - import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; @@ -13,21 +11,20 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import jakarta.annotation.Resource; import java.time.LocalDateTime; -import java.util.Objects; /** * 入站事件校验与幂等落库服务。 */ @Service public class EventInboxService { - @Resource - private InboxStore inboxStore; - @Resource - private ObjectMapper objectMapper; + @Resource private InboxStore inboxStore; + @Resource private ObjectMapper objectMapper; + @Value("${baiyue.region:CN}") - private String localRegion; + private String localRegion; @Transactional public EventReceiveResult receive(EventEnvelope envelope) { @@ -61,24 +58,9 @@ public class EventInboxService { if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { throw new IllegalArgumentException("相同 eventId 对应的 payloadHash 不一致,拒绝覆盖既有事件"); } - if (!hasSameEnvelopeIdentity(existing, envelope)) { - throw new IllegalArgumentException("相同 eventId 对应的客户或事件信封身份不一致,拒绝覆盖既有事件"); - } return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt()); } - private boolean hasSameEnvelopeIdentity(BridgeInbox existing, EventEnvelope envelope) { - return Objects.equals(existing.getTenantId(), envelope.tenantId()) - && 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.getAggregateType(), envelope.aggregateType()) - && Objects.equals(existing.getAggregateId(), envelope.aggregateId()) - && Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion()) - && Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion()); - } - private BridgeInbox toInbox(EventEnvelope envelope) { BridgeInbox inbox = new BridgeInbox(); inbox.setEventId(envelope.eventId()); @@ -87,7 +69,7 @@ public class EventInboxService { inbox.setEventVersion(envelope.eventVersion()); inbox.setSourceRegion(envelope.sourceRegion().name()); inbox.setTargetRegion(envelope.targetRegion().name()); - inbox.setTenantId(envelope.tenantId()); + inbox.setCustomerId(envelope.tenantId()); inbox.setAggregateType(envelope.aggregateType()); inbox.setAggregateId(envelope.aggregateId()); inbox.setAggregateVersion(envelope.aggregateVersion()); diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java new file mode 100644 index 000000000..bc8b76dea --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java @@ -0,0 +1,27 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +@Service +public class EventReplayService { + @Resource private BridgeInboxMapper inboxMapper; + + public void replayDead(String eventId, String operatorId, String reason) { + if (operatorId == null || operatorId.isBlank()) { + throw new IllegalArgumentException("人工补投必须提供操作人"); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("人工补投必须提供原因"); + } + String auditReason = abbreviate("manual replay by " + operatorId + ": " + reason, 1000); + if (inboxMapper.replayDead(eventId, auditReason) != 1) { + throw new IllegalStateException("事件不存在或未处于 DEAD 状态"); + } + } + + private static String abbreviate(String value, int maxLength) { + return value.length() <= maxLength ? value : value.substring(0, maxLength); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java new file mode 100644 index 000000000..3f2a904ab --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java @@ -0,0 +1,55 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.RegionCode; +import com.baiyue.eventbridge.config.EventDispatcherProperties; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +@Component +public class HttpLastlegEventClient implements LastlegEventClient { + @Resource private ObjectMapper objectMapper; + @Resource private EventDispatcherProperties properties; + + @Value("${baiyue.security.internal-service-token:}") + private String serviceToken; + + private HttpClient httpClient; + + @PostConstruct + void initializeHttpClient() { + this.httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build(); + } + + @Override + public String deliver(BridgeInbox row) throws Exception { + EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(), + row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()), + row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(), + row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), + objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); + String body = objectMapper.writeValueAsString(envelope); + HttpRequest request = HttpRequest.newBuilder(properties.consumerUrl()) + .timeout(properties.requestTimeout()) + .header("Content-Type", "application/json") + .header("X-Internal-Service-Token", serviceToken) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + JsonNode responseBody = objectMapper.readTree(response.body()); + if (response.statusCode() / 100 != 2 || responseBody == null + || !"200".equals(responseBody.path("code").asText())) { + throw new IllegalStateException("尾程消费失败: HTTP " + response.statusCode()); + } + return String.valueOf(response.statusCode()); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/LastlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/LastlegEventClient.java new file mode 100644 index 000000000..7fa21d3b3 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/LastlegEventClient.java @@ -0,0 +1,7 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.domain.BridgeInbox; + +public interface LastlegEventClient { + String deliver(BridgeInbox event) throws Exception; +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java index b397cae4c..d68c0f983 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java @@ -2,16 +2,15 @@ package com.baiyue.eventbridge.service; import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; -import lombok.RequiredArgsConstructor; +import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; /** * 基于 MyBatis-Plus 的入站幂等存储。 */ @Repository -@RequiredArgsConstructor public class MybatisInboxStore implements InboxStore { - private final BridgeInboxMapper mapper; + @Resource private BridgeInboxMapper mapper; @Override public BridgeInbox findByEventId(String eventId) { diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java new file mode 100644 index 000000000..39317dbe1 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java @@ -0,0 +1,88 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.config.EventDispatcherProperties; +import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.lang.management.ManagementFactory; +import java.time.LocalDateTime; +import java.util.UUID; + +@Component +@Slf4j +public class OverseasEventDispatcher { + @Resource private BridgeInboxMapper inboxMapper; + @Resource private BridgeDeliveryAttemptMapper attemptMapper; + @Resource private LastlegEventClient eventClient; + @Resource private EventDispatcherProperties properties; + private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID(); + + @Scheduled(fixedDelayString = "${baiyue.event-dispatcher.fixed-delay:3s}") + public void dispatch() { + for (String eventId : inboxMapper.dispatchCandidates(properties.batchSize())) { + dispatchOne(eventId); + } + } + + void dispatchOne(String eventId) { + if (inboxMapper.claim(eventId, leaseOwner, properties.leaseDuration().toSeconds()) != 1) { + return; + } + + BridgeInbox event = inboxMapper.selectById(eventId); + int retryAttempt = event.getRetryCount() == null ? 1 : event.getRetryCount() + 1; + int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId); + BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo); + try { + String responseCode = eventClient.deliver(event); + if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) { + throw new IllegalStateException("事件租约已失效,无法确认投递结果"); + } + finishAttempt(attempt, "DELIVERED", responseCode, null); + } catch (Exception exception) { + String error = abbreviate(exception.getMessage(), 1000); + inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), backoffSeconds(retryAttempt), error); + finishAttempt(attempt, retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY", null, error); + log.warn("尾程事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}", + eventId, retryAttempt, deliveryAttemptNo, error); + } + } + + private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) { + BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt(); + attempt.setEventId(event.getEventId()); + attempt.setSourceRegion(event.getSourceRegion()); + attempt.setTargetRegion(event.getTargetRegion()); + attempt.setAttemptNo(attemptNo); + attempt.setStatus("PROCESSING"); + attempt.setStartedAt(LocalDateTime.now()); + attemptMapper.insert(attempt); + return attempt; + } + + private void finishAttempt(BridgeDeliveryAttempt attempt, String status, String responseCode, String error) { + attempt.setStatus(status); + attempt.setResponseCode(responseCode); + attempt.setErrorMessage(error); + attempt.setFinishedAt(LocalDateTime.now()); + attemptMapper.updateById(attempt); + } + + private long backoffSeconds(int attemptNo) { + long initial = properties.initialBackoff().toSeconds(); + int shift = Math.min(Math.max(attemptNo - 1, 0), 20); + long delay = initial > Long.MAX_VALUE >> shift ? Long.MAX_VALUE : initial << shift; + return Math.min(delay, properties.maxBackoff().toSeconds()); + } + + private static String abbreviate(String value, int maxLength) { + String safe = value == null || value.isBlank() ? "未知投递错误" : value; + return safe.length() <= maxLength ? safe : safe.substring(0, maxLength); + } +} diff --git a/event-bridge-service/src/main/resources/application-dev.yml b/event-bridge-service/src/main/resources/application-dev.yml deleted file mode 100644 index 19d0a9ad0..000000000 --- a/event-bridge-service/src/main/resources/application-dev.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true} - username: ${EVENT_BRIDGE_DB_USERNAME:root} - password: ${EVENT_BRIDGE_DB_PASSWORD:123456} diff --git a/event-bridge-service/src/main/resources/application-test.yml b/event-bridge-service/src/main/resources/application-test.yml deleted file mode 100644 index 6b8765c21..000000000 --- a/event-bridge-service/src/main/resources/application-test.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${EVENT_BRIDGE_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} - username: ${EVENT_BRIDGE_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} - password: ${EVENT_BRIDGE_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml index 834b47a8d..c2f16ae02 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -1,18 +1,25 @@ server: - port: ${EVENT_BRIDGE_PORT:9300} + port: ${EVENT_BRIDGE_PORT:9312} spring: - profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6380} application: name: event-bridge-service config: import: - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true} + username: ${EVENT_BRIDGE_DB_USERNAME:root} + password: ${EVENT_BRIDGE_DB_PASSWORD:123456} cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} username: ${NACOS_USERNAME:nacos} password: ${NACOS_PASSWORD:nacos} discovery: @@ -22,7 +29,17 @@ spring: namespace: ${NACOS_NAMESPACE:} baiyue: - region: ${REGION_CODE:CN} + region: ${REGION_CODE:OVERSEAS} + event-dispatcher: + consumer-url: ${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events} + fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3s} + batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} + max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} + lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s} + initial-backoff: ${EVENT_DISPATCH_INITIAL_BACKOFF:5s} + max-backoff: ${EVENT_DISPATCH_MAX_BACKOFF:15m} + connect-timeout: ${EVENT_DISPATCH_CONNECT_TIMEOUT:3s} + request-timeout: ${EVENT_DISPATCH_REQUEST_TIMEOUT:10s} security: internal-service-token: ${INTERNAL_SERVICE_TOKEN:} @@ -35,14 +52,3 @@ management: springdoc: api-docs: path: /v3/api-docs - -logging: - file: - name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}" - logback: - rollingpolicy: - file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" - max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" - max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" - total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" - clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/event-bridge-service/src/main/resources/db/event-bridge.sql b/event-bridge-service/src/main/resources/db/event-bridge.sql index a7a52b539..4bbfe6bd4 100644 --- a/event-bridge-service/src/main/resources/db/event-bridge.sql +++ b/event-bridge-service/src/main/resources/db/event-bridge.sql @@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS bridge_outbox ( event_version INT NOT NULL COMMENT '事件契约版本', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', target_region VARCHAR(32) NOT NULL COMMENT '目标区域', - tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', + customer_id VARCHAR(128) NOT NULL COMMENT '租户标识', aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', aggregate_version BIGINT NOT NULL COMMENT '聚合版本', @@ -39,7 +39,7 @@ CREATE TABLE IF NOT EXISTS bridge_inbox ( event_version INT NOT NULL COMMENT '事件契约版本', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', target_region VARCHAR(32) NOT NULL COMMENT '目标区域', - tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', + customer_id VARCHAR(128) NOT NULL COMMENT '租户标识', aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', aggregate_version BIGINT NOT NULL COMMENT '聚合版本', diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java index 5c70836e7..1b8dfb9d2 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java @@ -1,43 +1,29 @@ package com.baiyue.eventbridge.security; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import jakarta.annotation.Resource; -import org.junit.jupiter.api.AfterEach; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; -import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.server.ResponseStatusException; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; -@DisplayName("内部服务令牌校验") -@SpringJUnitConfig(InternalServiceTokenGuard.class) -@TestPropertySource(properties = "baiyue.security.internal-service-token=a-secure-internal-token") class InternalServiceTokenGuardTest { - @Resource private InternalServiceTokenGuard guard; - - @AfterEach - void resetToken() { - ReflectionTestUtils.setField(guard, "expectedToken", "a-secure-internal-token"); - } @Test - void validTokenAllowsAccess() { + void 正确令牌允许访问() { + InternalServiceTokenGuard guard = new InternalServiceTokenGuard("a-secure-internal-token"); assertThatCode(() -> guard.verify("a-secure-internal-token")).doesNotThrowAnyException(); } @Test - void unconfiguredTokenDisablesReceiveEndpoint() { - ReflectionTestUtils.setField(guard, "expectedToken", ""); + void 未配置令牌时默认关闭接收接口() { + InternalServiceTokenGuard guard = new InternalServiceTokenGuard(""); assertThatThrownBy(() -> guard.verify("anything")) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("接口已关闭"); } @Test - void invalidTokenIsRejected() { - ReflectionTestUtils.setField(guard, "expectedToken", "expected"); + void 错误令牌被拒绝() { + InternalServiceTokenGuard guard = new InternalServiceTokenGuard("expected"); assertThatThrownBy(() -> guard.verify("wrong")) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("令牌无效"); 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 a0e9e5a42..1d5d45152 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 @@ -6,16 +6,8 @@ import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.RegionCode; import com.baiyue.eventbridge.domain.BridgeInbox; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.annotation.Resource; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; import org.springframework.dao.DuplicateKeyException; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.util.ReflectionTestUtils; import java.time.Instant; @@ -25,35 +17,17 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@DisplayName("跨区域事件收件箱") -@SpringJUnitConfig(classes = {EventInboxService.class, EventInboxServiceTest.TestConfig.class}) -@TestPropertySource(properties = "baiyue.region=OVERSEAS") class EventInboxServiceTest { - @Resource - private ObjectMapper objectMapper; - @MockBean - private InboxStore store; - @Resource - private EventInboxService service; - - @Configuration(proxyBeanMethods = false) - static class TestConfig { - @Bean - ObjectMapper objectMapper() { - return new ObjectMapper(); - } - } - - @AfterEach - void resetRegion() { - ReflectionTestUtils.setField(service, "localRegion", "OVERSEAS"); - } + private final ObjectMapper objectMapper = new ObjectMapper(); + private final InboxStore store = mock(InboxStore.class); + private final EventInboxService service = service("OVERSEAS"); @Test - void firstEventPersistsAndReturnsReceived() { + void 首次事件落库并返回已接收() { EventEnvelope envelope = envelope("sha256:placeholder"); envelope = withHash(envelope); @@ -62,19 +36,31 @@ class EventInboxServiceTest { } @Test - void identicalEventAndDigestDoesNotPersistAgain() { + 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())); + EventEnvelope envelope = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), + source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(), + source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), + source.traceId(), source.correlationId(), source.causationId(), source.payload(), legacyHash); + + assertThat(service.receive(envelope).result()).isEqualTo("已接收"); + } + + @Test + void 相同事件和摘要重复接收不再次写入() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - BridgeInbox existing = existing(envelope, envelope.payloadHash()); + BridgeInbox existing = existing(envelope.payloadHash()); when(store.findByEventId(envelope.eventId())).thenReturn(existing); assertThat(service.receive(envelope).result()).isEqualTo("重复"); } @Test - void sameEventWithDifferentDigestRejectsOverwrite() { + void 相同事件但摘要不同时拒绝覆盖() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - when(store.findByEventId(envelope.eventId())) - .thenReturn(existing(envelope, "sha256:" + "0".repeat(64))); + when(store.findByEventId(envelope.eventId())).thenReturn(existing("sha256:" + "0".repeat(64))); assertThatThrownBy(() -> service.receive(envelope)) .isInstanceOf(IllegalArgumentException.class) @@ -82,45 +68,20 @@ class EventInboxServiceTest { } @Test - void sameEventAndPayloadWithDifferentTenantRejectsOverwrite() { - EventEnvelope original = withHash(envelope("sha256:placeholder")); - EventEnvelope otherTenant = withTenant(original, "20002"); - when(store.findByEventId(otherTenant.eventId())) - .thenReturn(existing(original, original.payloadHash())); - - assertThatThrownBy(() -> service.receive(otherTenant)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("信封身份不一致"); - } - - @Test - void sameEventAndPayloadWithDifferentAggregateVersionRejectsOverwrite() { - EventEnvelope original = withHash(envelope("sha256:placeholder")); - EventEnvelope otherEnvelope = withAggregateVersion(original, 2L); - when(store.findByEventId(otherEnvelope.eventId())) - .thenReturn(existing(original, original.payloadHash())); - - assertThatThrownBy(() -> service.receive(otherEnvelope)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("信封身份不一致"); - } - - @Test - void duplicateInsertAfterRaceIsTreatedAsDuplicate() { + void 并发插入冲突后按既有记录判定为重复() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - when(store.findByEventId(envelope.eventId())) - .thenReturn(null, existing(envelope, envelope.payloadHash())); + when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope.payloadHash())); doThrow(new DuplicateKeyException("并发重复")).when(store).insert(any()); assertThat(service.receive(envelope).result()).isEqualTo("重复"); } @Test - void targetRegionDifferentFromCurrentDeploymentIsRejected() { - ReflectionTestUtils.setField(service, "localRegion", "CN"); + void 目标区域与当前部署区域不一致时拒绝接收() { + EventInboxService cnService = service("CN"); EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - assertThatThrownBy(() -> service.receive(envelope)) + assertThatThrownBy(() -> cnService.receive(envelope)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("目标区域"); } @@ -132,6 +93,14 @@ class EventInboxServiceTest { objectMapper.createObjectNode().put("orderId", "90001"), hash); } + private EventInboxService service(String region) { + EventInboxService service = new EventInboxService(); + ReflectionTestUtils.setField(service, "inboxStore", store); + ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + ReflectionTestUtils.setField(service, "localRegion", region); + return service; + } + private EventEnvelope withHash(EventEnvelope source) { String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(source.payload())); return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(), @@ -140,32 +109,9 @@ class EventInboxServiceTest { source.correlationId(), source.causationId(), source.payload(), hash); } - private EventEnvelope withTenant(EventEnvelope source, String tenantId) { - return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(), - source.sourceRegion(), source.targetRegion(), tenantId, source.aggregateType(), - source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), source.traceId(), - source.correlationId(), source.causationId(), source.payload(), source.payloadHash()); - } - - private EventEnvelope withAggregateVersion(EventEnvelope source, Long aggregateVersion) { - return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(), - source.sourceRegion(), source.targetRegion(), source.tenantId(), source.aggregateType(), - source.aggregateId(), aggregateVersion, source.handoverVersion(), source.traceId(), - source.correlationId(), source.causationId(), source.payload(), source.payloadHash()); - } - - private BridgeInbox existing(EventEnvelope envelope, String hash) { + private BridgeInbox existing(String hash) { BridgeInbox inbox = new BridgeInbox(); - inbox.setEventId(envelope.eventId()); - inbox.setTenantId(envelope.tenantId()); - inbox.setEventType(envelope.eventType()); - inbox.setEventVersion(envelope.eventVersion()); - inbox.setSourceRegion(envelope.sourceRegion().name()); - inbox.setTargetRegion(envelope.targetRegion().name()); - inbox.setAggregateType(envelope.aggregateType()); - inbox.setAggregateId(envelope.aggregateId()); - inbox.setAggregateVersion(envelope.aggregateVersion()); - inbox.setHandoverVersion(envelope.handoverVersion()); + inbox.setEventId("550e8400-e29b-41d4-a716-446655440000"); inbox.setPayloadHash(hash); inbox.setReceivedAt(LocalDateTime.of(2026, 7, 29, 18, 0)); return inbox; diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java new file mode 100644 index 000000000..66b740ddf --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java @@ -0,0 +1,36 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +class EventReplayServiceTest { + private final BridgeInboxMapper mapper = mock(BridgeInboxMapper.class); + private final EventReplayService service = service(); + + @Test + void requeuesDeadEventWithAuditReason() { + when(mapper.replayDead(eq("event-1"), contains("incident-42"))).thenReturn(1); + + service.replayDead("event-1", "operator-7", "incident-42 approved"); + + verify(mapper).replayDead(eq("event-1"), contains("operator-7")); + } + + @Test + void rejectsEventThatIsNotDead() { + when(mapper.replayDead(anyString(), anyString())).thenReturn(0); + + assertThatThrownBy(() -> service.replayDead("event-1", "operator-7", "retry")) + .isInstanceOf(IllegalStateException.class); + } + + private EventReplayService service() { + EventReplayService service = new EventReplayService(); + ReflectionTestUtils.setField(service, "inboxMapper", mapper); + return service; + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java new file mode 100644 index 000000000..f3f727fb0 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java @@ -0,0 +1,94 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.config.EventDispatcherProperties; +import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.net.URI; +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class OverseasEventDispatcherTest { + private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class); + private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class); + private final LastlegEventClient eventClient = mock(LastlegEventClient.class); + private OverseasEventDispatcher dispatcher; + private BridgeInbox event; + + @BeforeEach + void setUp() { + EventDispatcherProperties properties = new EventDispatcherProperties( + URI.create("https://lastleg.internal/events"), 50, 3, + Duration.ofSeconds(30), Duration.ofSeconds(5), Duration.ofMinutes(1), + Duration.ofSeconds(3), Duration.ofSeconds(10)); + dispatcher = new OverseasEventDispatcher(); + ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper); + ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper); + ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient); + ReflectionTestUtils.setField(dispatcher, "properties", properties); + event = new BridgeInbox(); + event.setEventId("event-1"); + event.setSourceRegion("CN"); + event.setTargetRegion("OVERSEAS"); + event.setRetryCount(0); + when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1); + when(inboxMapper.selectById("event-1")).thenReturn(event); + when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1); + } + + @Test + void successfulDeliveryIsConfirmedAndAudited() throws Exception { + when(eventClient.deliver(event)).thenReturn("200"); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verify(inboxMapper).markProcessed(eq("event-1"), anyString()); + assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); + } + + @Test + void failedDeliveryIsScheduledForRetry() throws Exception { + when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down")); + + dispatcher.dispatchOne("event-1"); + + verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down")); + assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY"); + } + + @Test + void finalFailureIsMovedToDeadState() throws Exception { + event.setRetryCount(2); + when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down")); + + dispatcher.dispatchOne("event-1"); + + assertThat(updatedAttempt().getStatus()).isEqualTo("DEAD"); + } + + @Test + void eventClaimedByAnotherInstanceIsSkipped() { + when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(0); + + dispatcher.dispatchOne("event-1"); + + verifyNoInteractions(eventClient); + verify(attemptMapper, never()).insert(any(BridgeDeliveryAttempt.class)); + } + + private BridgeDeliveryAttempt updatedAttempt() { + ArgumentCaptor captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); + verify(attemptMapper).updateById(captor.capture()); + return captor.getValue(); + } +} diff --git a/gateway-overseas/AGENTS.md b/gateway-overseas/AGENTS.md new file mode 100644 index 000000000..8d4aff60e --- /dev/null +++ b/gateway-overseas/AGENTS.md @@ -0,0 +1,30 @@ +# Gateway 模块指引 + +## 职责边界 + +- 负责海外 API 统一入口、路由、RS256/JWKS 验签、Audience 和必要 Claims 校验。 +- 不保存签名私钥,不依赖国内 Redis 会话,不在网关实现尾程业务逻辑。 + +## 快速索引 + +| 功能 | 入口 | +|---|---| +| 安全链配置 | `src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java` | +| Audience 校验 | `config/AudienceJwtValidator.java` | +| 必要 Claims 校验 | `config/RequiredClaimsJwtValidator.java` | +| 安全配置项 | `config/GatewaySecurityProperties.java` | +| 可信身份头传递 | `filter/TrustedIdentityHeaderFilter.java` | +| 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` | +| 测试 | `src/test/java/com/baiyue/gateway/config/` | + +## 安全规则 + +- 生产令牌 `aud` 必须包含 `lastleg-api`,并包含配置要求的身份声明。 +- 网关必须清除外部请求伪造的可信身份头,仅传递验签后生成的身份信息。 +- JWKS 地址、Issuer 和 Audience 必须外部配置;不得在源码写死生产密钥。 + +## 修改与验证 + +- 定向验证:`mvn -pl gateway -am test`。 +- 安全变更覆盖合法令牌、错误 Audience、缺失 Claims、过期/错误签名和身份头伪造测试。 + diff --git a/gateway-overseas/pom.xml b/gateway-overseas/pom.xml new file mode 100644 index 000000000..01f656726 --- /dev/null +++ b/gateway-overseas/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + + com.baiyue.firstleg + baiyue-firstleg-platform + 1.0.0-SNAPSHOT + ../pom.xml + + gateway-overseas + gateway-overseas + + + + org.springdoc + springdoc-openapi-starter-webflux-ui + + + com.baiyue.shared + baiyue-common-core + ${project.version} + + + org.springframework.cloud + spring-cloud-starter-gateway + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + + + org.springframework.cloud + spring-cloud-starter-loadbalancer + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + com.alibaba.cloud + spring-cloud-starter-alibaba-sentinel + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-test + test + + + io.projectreactor + reactor-test + test + + + org.springframework.bootspring-boot-maven-plugin + diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/BaiyueGatewayOverseasApplication.java b/gateway-overseas/src/main/java/com/baiyue/gateway/BaiyueGatewayOverseasApplication.java new file mode 100644 index 000000000..8514ad509 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/BaiyueGatewayOverseasApplication.java @@ -0,0 +1,14 @@ +package com.baiyue.gateway; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * API 网关启动入口,承载统一路由、认证校验和可信身份头转发。 + */ +@SpringBootApplication +public class BaiyueGatewayOverseasApplication { + public static void main(String[] args) { + SpringApplication.run(BaiyueGatewayOverseasApplication.class, args); + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java new file mode 100644 index 000000000..5dae7304a --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java @@ -0,0 +1,32 @@ +package com.baiyue.gateway.config; + +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.ReactiveAuthorizationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.web.server.authorization.AuthorizationContext; +import reactor.core.publisher.Mono; + +/** + * 网关应用范围授权器,根据 JWT 的 {@code app_code} 声明隔离商户端与平台端接口。 + */ +final class AppScopeAuthorizationManager implements ReactiveAuthorizationManager { + private final String expectedAppCode; + + AppScopeAuthorizationManager(String expectedAppCode) { + this.expectedAppCode = expectedAppCode; + } + + @Override + public Mono check(Mono authentication, + AuthorizationContext context) { + return authentication.filter(Authentication::isAuthenticated) + .map(this::hasExpectedScope).map(AuthorizationDecision::new) + .defaultIfEmpty(new AuthorizationDecision(false)); + } + + private boolean hasExpectedScope(Authentication authentication) { + return authentication instanceof JwtAuthenticationToken jwt + && expectedAppCode.equals(jwt.getToken().getClaimAsString("app_code")); + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/AudienceJwtValidator.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/AudienceJwtValidator.java new file mode 100644 index 000000000..0305a7498 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/AudienceJwtValidator.java @@ -0,0 +1,23 @@ +package com.baiyue.gateway.config; + +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; + +final class AudienceJwtValidator implements OAuth2TokenValidator { + private final String requiredAudience; + + AudienceJwtValidator(String requiredAudience) { + this.requiredAudience = requiredAudience; + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt jwt) { + if (jwt.getAudience().contains(requiredAudience)) { + return OAuth2TokenValidatorResult.success(); + } + return OAuth2TokenValidatorResult.failure(new OAuth2Error( + "invalid_token", "JWT audience 不允许访问海外 API", null)); + } +} 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 new file mode 100644 index 000000000..50a208bca --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java @@ -0,0 +1,84 @@ +package com.baiyue.gateway.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; +import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; +import org.springframework.security.web.server.SecurityWebFilterChain; +import reactor.core.publisher.Mono; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 海外网关响应式安全配置,使用国内认证中心公开的 JWKS 验证 JWT。 + */ +@Configuration +@EnableConfigurationProperties(GatewaySecurityProperties.class) +public class GatewaySecurityConfig { + @Bean + SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, + GatewaySecurityProperties properties, + ObjectMapper objectMapper) { + var exchanges = http.authorizeExchange(); + properties.permitPaths().forEach(path -> exchanges.pathMatchers(path).permitAll()); + exchanges.pathMatchers("/api/merchant/**") + .access(new AppScopeAuthorizationManager("MERCHANT")); + exchanges.pathMatchers("/api/platform/**") + .access(new AppScopeAuthorizationManager("PLATFORM")); + exchanges.anyExchange().authenticated(); + return http.csrf(ServerHttpSecurity.CsrfSpec::disable) + .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable) + .formLogin(ServerHttpSecurity.FormLoginSpec::disable) + .oauth2ResourceServer(oauth -> oauth.jwt(jwt -> { }) + .authenticationEntryPoint((exchange, exception) -> writeError(exchange, objectMapper, + HttpStatus.UNAUTHORIZED, "A0401", "未登录或令牌已失效"))) + .exceptionHandling(errors -> errors.accessDeniedHandler((exchange, exception) -> + writeError(exchange, objectMapper, HttpStatus.FORBIDDEN, "A0403", "无权访问该资源"))) + .build(); + } + + @Bean + ReactiveJwtDecoder reactiveJwtDecoder(GatewaySecurityProperties properties) { + NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder + .withJwkSetUri(properties.jwkSetUri()) + .jwsAlgorithms(algorithms -> { + algorithms.add(SignatureAlgorithm.RS256); + algorithms.add(SignatureAlgorithm.ES256); + }) + .build(); + decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>( + JwtValidators.createDefaultWithIssuer(properties.issuer()), + new AudienceJwtValidator(properties.audience()), + new RequiredClaimsJwtValidator())); + return decoder; + } + + static Mono writeError(org.springframework.web.server.ServerWebExchange exchange, + ObjectMapper mapper, HttpStatus status, String code, String message) { + exchange.getResponse().setStatusCode(status); + exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); + Map body = new LinkedHashMap<>(); + body.put("code", code); + body.put("message", message); + body.put("data", null); + body.put("requestId", exchange.getRequest().getId()); + body.put("timestamp", Instant.now().toString()); + try { + byte[] bytes = mapper.writeValueAsBytes(body); + return exchange.getResponse().writeWith( + Mono.just(exchange.getResponse().bufferFactory().wrap(bytes))); + } catch (Exception exception) { + return exchange.getResponse().setComplete(); + } + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java new file mode 100644 index 000000000..3dab56ba2 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java @@ -0,0 +1,27 @@ +package com.baiyue.gateway.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +/** + * 海外网关安全配置,只保存验签所需的签发方和公钥集地址。 + */ +@ConfigurationProperties(prefix = "baiyue.security") +public record GatewaySecurityProperties(String jwkSetUri, + String issuer, + String audience, + List permitPaths) { + public GatewaySecurityProperties { + if (jwkSetUri == null || jwkSetUri.isBlank()) { + throw new IllegalArgumentException("必须配置 JWT_JWK_SET_URI"); + } + if (issuer == null || issuer.isBlank()) { + throw new IllegalArgumentException("必须配置 JWT_ISSUER"); + } + if (audience == null || audience.isBlank()) { + throw new IllegalArgumentException("必须配置 JWT_AUDIENCE"); + } + permitPaths = permitPaths == null ? List.of() : List.copyOf(permitPaths); + } +} 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 new file mode 100644 index 000000000..d8621a342 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java @@ -0,0 +1,26 @@ +package com.baiyue.gateway.config; + +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.util.List; + +/** 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"); + + @Override + public OAuth2TokenValidatorResult validate(Jwt jwt) { + for (String claim : REQUIRED_CLAIMS) { + Object value = jwt.getClaims().get(claim); + if (value == null || value.toString().isBlank()) { + return OAuth2TokenValidatorResult.failure(new OAuth2Error( + "invalid_token", "JWT 缺少必要声明: " + claim, null)); + } + } + return OAuth2TokenValidatorResult.success(); + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java new file mode 100644 index 000000000..80b2b320b --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java @@ -0,0 +1,73 @@ +package com.baiyue.gateway.filter; + +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.util.List; + +/** + * 网关可信身份头过滤器,清除外部伪造身份头,并从已认证 JWT 注入用户、租户和应用范围信息。 + */ +@Component +public class TrustedIdentityHeaderFilter implements GlobalFilter, Ordered { + private static final List TRUSTED_HEADERS = List.of( + "X-User-Id", "X-Membership-Id", "X-Tenant-Id", "X-Operator-Tenant-Id", + "X-App-Code", "X-Client-Type" + ); + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + return exchange.getPrincipal() + .cast(Authentication.class) + .defaultIfEmpty(new AnonymousAuthentication()) + .flatMap(authentication -> { + var requestBuilder = exchange.getRequest().mutate(); + requestBuilder.headers(headers -> TRUSTED_HEADERS.forEach(headers::remove)); + + if (authentication instanceof JwtAuthenticationToken jwt && authentication.isAuthenticated()) { + put(requestBuilder, "X-User-Id", jwt.getToken().getSubject()); + put(requestBuilder, "X-Membership-Id", claim(jwt, "membership_id")); + put(requestBuilder, "X-Tenant-Id", claim(jwt, "tenant_id")); + put(requestBuilder, "X-Operator-Tenant-Id", claim(jwt, "operator_tenant_id")); + put(requestBuilder, "X-App-Code", claim(jwt, "app_code")); + put(requestBuilder, "X-Client-Type", claim(jwt, "client_type")); + } + return chain.filter(exchange.mutate().request(requestBuilder.build()).build()); + }); + } + + private static String claim(JwtAuthenticationToken jwt, String name) { + Object value = jwt.getToken().getClaims().get(name); + return value == null ? null : value.toString(); + } + + private static void put(org.springframework.http.server.reactive.ServerHttpRequest.Builder builder, + String name, + String value) { + if (value != null && !value.isBlank()) { + builder.header(name, value); + } + } + + @Override + public int getOrder() { + return -100; + } + + private static final class AnonymousAuthentication implements Authentication { + @Override public java.util.Collection getAuthorities() { return List.of(); } + @Override public Object getCredentials() { return ""; } + @Override public Object getDetails() { return null; } + @Override public Object getPrincipal() { return "anonymous"; } + @Override public boolean isAuthenticated() { return false; } + @Override public void setAuthenticated(boolean isAuthenticated) { } + @Override public String getName() { return "anonymous"; } + } +} + diff --git a/gateway-overseas/src/main/resources/application-overseas.yml b/gateway-overseas/src/main/resources/application-overseas.yml new file mode 100644 index 000000000..c1c32029e --- /dev/null +++ b/gateway-overseas/src/main/resources/application-overseas.yml @@ -0,0 +1,69 @@ +spring: + config: + activate: + on-profile: overseas + cloud: + gateway: + discovery: + locator: + enabled: false + routes: + - id: lastleg-service + uri: lb://lastleg-service + predicates: ["Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**"] + - id: warehouse-overseas-service + uri: lb://warehouse-overseas-service + predicates: ["Path=/api/merchant/v1/warehouses/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**"] + - id: file-service + uri: lb://file-service + predicates: ["Path=/api/merchant/v1/files/**,/api/platform/v1/files/**"] + - id: print-service + uri: lb://print-service + predicates: ["Path=/api/merchant/v1/print/**,/api/platform/v1/print/**"] + - id: lastleg-openapi + uri: lb://lastleg-service + predicates: [Path=/lastleg-service/v3/api-docs/**] + filters: [StripPrefix=1] + - id: warehouse-openapi + uri: lb://warehouse-overseas-service + predicates: [Path=/warehouse-overseas-service/v3/api-docs/**] + filters: [StripPrefix=1] + - id: file-openapi + uri: lb://file-service + predicates: [Path=/file-service/v3/api-docs/**] + filters: [StripPrefix=1] + - id: print-openapi + uri: lb://print-service + predicates: [Path=/print-service/v3/api-docs/**] + filters: [StripPrefix=1] + - id: audit-openapi + uri: lb://audit-service + predicates: [Path=/audit-service/v3/api-docs/**] + filters: [StripPrefix=1] + - id: integration-openapi + uri: lb://integration-service + predicates: [Path=/integration-service/v3/api-docs/**] + filters: [StripPrefix=1] + - id: event-bridge-openapi + uri: lb://event-bridge-service + predicates: [Path=/event-bridge-service/v3/api-docs/**] + filters: [StripPrefix=1] + +baiyue: + deployment: + region-code: ${REGION_CODE:OVERSEAS} + cell: ${DEPLOYMENT_CELL:prod-overseas} + data-residency: ${DATA_RESIDENCY:OVERSEAS} + +springdoc: + swagger-ui: + urls: + - { name: lastleg-merchant, url: /lastleg-service/v3/api-docs/merchant } + - { name: warehouse-merchant, url: /warehouse-overseas-service/v3/api-docs/merchant } + - { 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 } + - { name: print-merchant, url: /print-service/v3/api-docs/merchant } + - { name: audit, url: /audit-service/v3/api-docs } + - { name: integration, url: /integration-service/v3/api-docs } + - { name: event-bridge, url: /event-bridge-service/v3/api-docs } diff --git a/gateway-overseas/src/main/resources/application.yml b/gateway-overseas/src/main/resources/application.yml new file mode 100644 index 000000000..053a362a3 --- /dev/null +++ b/gateway-overseas/src/main/resources/application.yml @@ -0,0 +1,67 @@ +server: + port: ${GATEWAY_PORT:9300} + +spring: + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6380} + application: + name: gateway-overseas + profiles: + default: overseas + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration + config: + import: + - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true + - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + cloud: + nacos: + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} + username: ${NACOS_USERNAME:nacos} + password: ${NACOS_PASSWORD:nacos} + discovery: + enabled: ${NACOS_DISCOVERY_ENABLED:true} + register-enabled: ${NACOS_REGISTER_ENABLED:true} + group: ${NACOS_GROUP:BAIYUE} + namespace: ${NACOS_NAMESPACE:public} + config: + namespace: ${NACOS_NAMESPACE:public} + gateway: + discovery: + locator: + enabled: false + +baiyue: + security: + issuer: ${JWT_ISSUER:https://auth.example.com} + audience: ${JWT_AUDIENCE:lastleg-api} + jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json} + permit-paths: + - /actuator/health + - /swagger-ui/** + - /webjars/** + - /v3/api-docs/** + - /lastleg-service/v3/api-docs/** + - /warehouse-overseas-service/v3/api-docs/** + - /file-service/v3/api-docs/** + - /print-service/v3/api-docs/** + - /audit-service/v3/api-docs/** + - /integration-service/v3/api-docs/** + - /event-bridge-service/v3/api-docs/** + +management: + endpoints: + web: + exposure: + include: health,info,prometheus + endpoint: + health: + probes: + enabled: true + +springdoc: + api-docs.enabled: ${SWAGGER_ENABLED:false} + swagger-ui.enabled: ${SWAGGER_ENABLED:false} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java new file mode 100644 index 000000000..21e2371a7 --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java @@ -0,0 +1,55 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Instant; + +class AppScopeAuthorizationManagerTest { + @Test + void acceptsMatchingApplicationScope() { + var manager = new AppScopeAuthorizationManager("MERCHANT"); + StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null)) + .expectNextMatches(decision -> decision.isGranted()) + .verifyComplete(); + } + + @Test + void rejectsDifferentApplicationScope() { + var manager = new AppScopeAuthorizationManager("PLATFORM"); + StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null)) + .expectNextMatches(decision -> !decision.isGranted()) + .verifyComplete(); + } + + @Test + void rejectsMissingApplicationScope() { + var manager = new AppScopeAuthorizationManager("PLATFORM"); + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "none") + .subject("1") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(60)) + .build(); + StepVerifier.create(manager.check(Mono.just(new JwtAuthenticationToken( + jwt, java.util.List.of(new SimpleGrantedAuthority("authenticated")))), null)) + .expectNextMatches(decision -> !decision.isGranted()) + .verifyComplete(); + } + + private JwtAuthenticationToken authentication(String appCode) { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "none") + .subject("1") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(60)) + .claim("app_code", appCode) + .build(); + return new JwtAuthenticationToken(jwt, + java.util.List.of(new SimpleGrantedAuthority("authenticated"))); + } +} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/AudienceJwtValidatorTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/AudienceJwtValidatorTest.java new file mode 100644 index 000000000..474c2ec6f --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/AudienceJwtValidatorTest.java @@ -0,0 +1,33 @@ +package com.baiyue.gateway.config; + +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; + +class AudienceJwtValidatorTest { + private final AudienceJwtValidator validator = new AudienceJwtValidator("lastleg-api"); + + @Test + void acceptsTokenForLastlegApi() { + assertThat(validator.validate(jwt(List.of("cn-api", "lastleg-api"))).hasErrors()).isFalse(); + } + + @Test + void rejectsTokenForAnotherApi() { + assertThat(validator.validate(jwt(List.of("cn-api"))).hasErrors()).isTrue(); + } + + private Jwt jwt(List audiences) { + return Jwt.withTokenValue("token") + .header("alg", "RS256") + .subject("1001") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .audience(audiences) + .build(); + } +} 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 new file mode 100644 index 000000000..db83eae5a --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java @@ -0,0 +1,36 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +class RequiredClaimsJwtValidatorTest { + private final RequiredClaimsJwtValidator validator = new RequiredClaimsJwtValidator(); + + @Test + void acceptsCompleteIdentityClaims() { + assertThat(validator.validate(jwt(true)).hasErrors()).isFalse(); + } + + @Test + void rejectsMissingPermissionVersion() { + assertThat(validator.validate(jwt(false)).hasErrors()).isTrue(); + } + + private Jwt jwt(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"); + if (includePermissionVersion) { + builder.claim("permission_version", 3L); + } + return builder.build(); + } +} diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md new file mode 100644 index 000000000..7a71049e7 --- /dev/null +++ b/lastleg-service/AGENTS.md @@ -0,0 +1,72 @@ +# Lastleg Service 模块指引 + +## 职责与边界 + +- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立尾程订单、明细冻结快照和初始状态历史。 +- 负责尾程订单列表、详情、导出和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业。 +- 不查询头程数据库,不调用头程接口补齐建单字段;事件快照缺失时应拒绝消费并保留可诊断错误。 + +## 快速索引 + +| 功能 | 入口 | +|---|---| +| 事件接收、订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` | +| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java` | +| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | +| 订单查询与更新 | `service/LastlegOrderService.java` | +| 业务 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/service/PalletAllocator.java` | +| PDA 入仓、实收、上架、合托、完成 | `inboundplan/pda/controller/`、`inboundplan/pda/service/InboundPdaWorkflowService.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` | +| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` | +| 测试 | `src/test/java/com/baiyue/lastleg/` | + +## 数据库表索引与注释约束 + +| 业务域 | 表名 | 中文含义 | +|---|---|---| +| 核心订单与事件 | `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_*` | 海外采购及美国卡车运输 | + +- 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义,不得只写英文、字段名或类型名。 +- `SchemaChineseCommentContractTest` 自动扫描全部 `ll_` 表,无需维护表名白名单;新增字段必须同步更新基线 SQL、建表增量和存量备注迁移,增量脚本的 `ADD/MODIFY COLUMN` 不得丢失中文备注。 +- 表备注、字段备注和索引变更必须可重复执行;存量库的备注修订通过统一增量脚本落地,不依赖基线建表语句。 + +## 事件建单规则 + +- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。 +- 同一实际集装箱 `containerNo` 生成一个共享业务尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。整柜操作不使用 `cabinetNo`。 +- `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。 +- 其他订单类型只建立入仓单维度的尾程订单与冻结明细,来源 `palletCount` 强制为 0;后续 PDA 托盘属于海外仓作业资源。 +- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。 +- 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 +- 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 + +## 查询与租户约束 + +- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。 +- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 +- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 +- 货物废弃只使用可核对 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 入可用库存。 +- 派车状态只允许 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`。 + +## 修改与验证 + +- 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。 +- 事件契约变化同时检查 `common-event-contract` 版本、Event Bridge 消费方和旧 Payload 哈希兼容。 +- 定向验证:`mvn -pl lastleg -am test`。 +- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。 diff --git a/lastleg-service/pom.xml b/lastleg-service/pom.xml new file mode 100644 index 000000000..4f86b09f9 --- /dev/null +++ b/lastleg-service/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml + lastleg-service + + com.baiyue.sharedcommon-event-contract${project.version} + com.baiyue.sharedbaiyue-common-openapi${project.version} +com.baomidoumybatis-plus-spring-boot3-starter + com.baiyue.sharedbaiyue-common-web${project.version} + com.baiyue.sharedbaiyue-common-security${project.version} + com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery + com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config + com.alibaba.cloudspring-cloud-starter-alibaba-sentinel + com.alibaba.cloudspring-cloud-starter-stream-rocketmq + org.springframework.bootspring-boot-starter-actuator + com.mysqlmysql-connector-jruntime + org.springframework.bootspring-boot-starter-testtest + + 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 new file mode 100644 index 000000000..3b63ff22f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 尾程微服务预留模块启动入口,初始化 Spring Boot 应用并扫描尾程 Mapper。 + */ +@MapperScan("com.baiyue.lastleg.mapper") +@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/controller/LastlegHealthController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java new file mode 100644 index 000000000..abb4a10cb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 尾程预留服务状态接口,用于验证尾程服务边界及基础可用性。 + */ +@Tag( + name = "Last-leg service status / 尾程服务状态", + description = "Last-leg reserved service availability API. / 尾程预留服务的可用性接口。" +) +@RestController +@RequestMapping("/api/platform/v1/last-leg") +public class LastlegHealthController { + @Operation( + summary = "Get service status / 查询服务状态", + description = "Returns the logical status of the reserved lastleg-service boundary. / 返回预留 lastleg-service 边界的逻辑运行状态。" + ) + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Service is available / 服务可用" + ) + @GetMapping("/status") + public ApiResponse> status() { + return ApiResponse.success(Map.of("service", "lastleg-service", "status", "UP")); + } +} 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 new file mode 100644 index 000000000..d3373c2db --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -0,0 +1,113 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.security.AuthenticatedUser; +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.service.LastlegOrderService; +import com.baiyue.lastleg.service.LoadingCompletedConsumer; +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.HttpHeaders; +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.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; + +@RestController +@Tag(name = "Tail-leg orders") +public class LastlegOrderController { + @Resource private LoadingCompletedConsumer consumer; + @Resource private LastlegOrderService orderService; + @Resource private InternalServiceTokenGuard tokenGuard; + + @PostMapping("/internal/lastleg/events") + @Operation(summary = "Receive a first-leg loading-completed event") + public ApiResponse receive( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestBody EventEnvelope event) { + tokenGuard.verify(token); + return ApiResponse.success(consumer.consume(event)); + } + + @GetMapping("/api/platform/v1/last-leg/orders") + @Operation(summary = "Page tail-leg order rows") + public ApiResponse> list( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid LastlegOrderRequests.Query query) { + return ApiResponse.success(orderService.page(scope(user, customerId), query)); + } + + @GetMapping("/api/platform/v1/last-leg/orders/{id}") + @Operation(summary = "Get one inbound-code-level tail-leg order") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id) { + return ApiResponse.success(orderService.detail(scope(user, customerId), id)); + } + + @GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv") + @Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV") + public ResponseEntity export( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid LastlegOrderRequests.Query query) { + byte[] body = orderService.exportCsv(scope(user, customerId), query); + return ResponseEntity.ok() + .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=lastleg-orders.csv") + .body(body); + } + + @PutMapping("/api/platform/v1/last-leg/orders/{id}/destination") + @Operation(summary = "Transfer one order to a destination warehouse") + public ApiResponse transferOne( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id, + @Valid @RequestBody LastlegOrderRequests.SingleTransferDestination request) { + return ApiResponse.success(orderService.transferDestination(scope(user, customerId), request.withOrderId(id))); + } + + @PutMapping("/api/platform/v1/last-leg/orders/destination") + @Operation(summary = "Transfer multiple orders to a destination warehouse") + public ApiResponse transferBatch( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid @RequestBody LastlegOrderRequests.TransferDestination request) { + return ApiResponse.success(orderService.transferDestination(scope(user, customerId), request)); + } + + private String scope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Platform queries must specify a customer"); + } + return requestedCustomer; + } + return String.valueOf(user.tenantId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/DeliveryMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/DeliveryMapperConfiguration.java new file mode 100644 index 000000000..8d19ed2a3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/DeliveryMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.delivery; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.delivery.mapper") +public class DeliveryMapperConfiguration { +} 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 new file mode 100644 index 000000000..49088ef0a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -0,0 +1,74 @@ +package com.baiyue.lastleg.delivery.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.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.dto.DeliveryResponses; +import com.baiyue.lastleg.delivery.service.DeliveryTransferService; +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.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg") +@Tag(name = "Outbound and delivery") +public class DeliveryTransferController { + @Resource private DeliveryTransferService service; + + @PostMapping("/dispatches/{dispatchNo}/transfer-delivery") + @Operation(summary = "Create one pending outbound order and one pending delivery order") + public ApiResponse transfer( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, + @RequestParam(required = false) String customerId, + @Valid @RequestBody DeliveryRequests.Transfer request) { + return ApiResponse.success(service.transfer(scope(user, customerId), dispatchNo, operator(user), request)); + } + + @GetMapping("/outbound-orders") + public ApiResponse> outboundPage( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @Valid DeliveryRequests.Query query) { + return ApiResponse.success(service.outboundPage(scope(user, customerId), query)); + } + + @GetMapping("/outbound-orders/{outboundNo}") + public ApiResponse outboundDetail( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @PathVariable String outboundNo, @Valid DeliveryRequests.PalletQuery query) { + return ApiResponse.success(service.outboundDetail(scope(user, customerId), outboundNo, query)); + } + + @GetMapping("/delivery-orders") + public ApiResponse> deliveryPage( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @Valid DeliveryRequests.Query query) { + return ApiResponse.success(service.deliveryPage(scope(user, customerId), query)); + } + + @GetMapping("/delivery-orders/{deliveryNo}") + public ApiResponse deliveryDetail( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @PathVariable String deliveryNo, @Valid DeliveryRequests.PalletQuery query) { + return ApiResponse.success(service.deliveryDetail(scope(user, customerId), deliveryNo, query)); + } + + private String scope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); + } + return requestedCustomer.trim(); + } + return String.valueOf(user.tenantId()); + } + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java new file mode 100644 index 000000000..ac8083c6f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.delivery.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public final class DeliveryRequests { + private DeliveryRequests() {} + + public record Transfer(@NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, + String documentNo, String warehouseId, String status) { + public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record PalletQuery(@Min(1) long page, @Min(1) @Max(200) long size, + String destinationWarehouseCode) { + public PalletQuery { if (page == 0) page = 1; if (size == 0) size = 20; } + } +} 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 new file mode 100644 index 000000000..643058a20 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.delivery.dto; + +import com.baiyue.common.core.api.PageResult; +import java.time.LocalDateTime; + +public final class DeliveryResponses { + private DeliveryResponses() {} + + public record TransferResult(String dispatchNo, String outboundNo, String outboundStatus, + String deliveryNo, String deliveryStatus, int palletCount, + int packageCount, boolean replayed) {} + + public record OutboundRow(Long id, String outboundNo, String dispatchNo, String overseasWarehouseId, + String status, int palletCount, int packageCount, LocalDateTime createdAt) {} + + public record DeliveryRow(Long id, String deliveryNo, String outboundNo, String dispatchNo, + 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) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java new file mode 100644 index 000000000..ee1b4f809 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.delivery.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** Delivery document linked one-to-one to its outbound document and dispatch. */ +@Data +@TableName("ll_delivery_order") +public class DeliveryOrder { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private String deliveryNo; + private Long outboundOrderId; + private String outboundNo; + private Long dispatchOrderId; + private String dispatchNo; + private String overseasWarehouseId; + private String status; + private Integer packageCount; + private Integer palletCount; + private String idempotencyKey; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java new file mode 100644 index 000000000..0f438791f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.delivery.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** Outbound document created when a dispatch is transferred to delivery. */ +@Data +@TableName("ll_outbound_order") +public class OutboundOrder { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private String outboundNo; + private Long dispatchOrderId; + private String dispatchNo; + private String overseasWarehouseId; + private String status; + private Integer packageCount; + private Integer palletCount; + private String idempotencyKey; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java new file mode 100644 index 000000000..8145f8f82 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.delivery.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** Pallet-level immutable snapshot belonging to one outbound document. */ +@Data +@TableName("ll_outbound_pallet_item") +public class OutboundPalletItem { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private Long outboundOrderId; + private Long warehousePalletId; + private String palletNo; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private Integer packageCount; + private String orderRefs; + private String inboundCode; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java new file mode 100644 index 000000000..7d2641cf5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface DeliveryOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1") + DeliveryOrder findByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo); + @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + DeliveryOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key); + @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} LIMIT 1 FOR UPDATE") + DeliveryOrder lockByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java new file mode 100644 index 000000000..e16968f70 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface OutboundOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND outbound_no=#{outboundNo} LIMIT 1") + OutboundOrder findByNo(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo); + @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + OutboundOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key); + @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE") + OutboundOrder lockByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPalletItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPalletItemMapper.java new file mode 100644 index 000000000..32bb24aa1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPalletItemMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +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 OutboundPalletItemMapper extends BaseMapper { + @Select("SELECT * FROM ll_outbound_pallet_item WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} ORDER BY id") + List findByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId); +} 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 new file mode 100644 index 000000000..b7e335447 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -0,0 +1,213 @@ +package com.baiyue.lastleg.delivery.service; + +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.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.dto.DeliveryResponses; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +@Service +public class DeliveryTransferService { + @Resource private DispatchOrderMapper dispatchOrders; + @Resource private DispatchItemMapper dispatchItems; + @Resource private OutboundOrderMapper outboundOrders; + @Resource private OutboundPalletItemMapper outboundPallets; + @Resource private DeliveryOrderMapper deliveryOrders; + @Resource private InboundPlanPalletAllocationMapper planAllocations; + @Resource private InboundPlanLineMapper planLines; + @Resource private LastlegOrderMapper tailOrders; + @Resource private LastlegOrderStatusHistoryMapper histories; + + @Transactional + public DeliveryResponses.TransferResult transfer(String customerId, String dispatchNo, String operatorId, + DeliveryRequests.Transfer request) { + DispatchOrder found = dispatchOrders.findByDispatchNo(customerId, dispatchNo); + if (found == null) throw notFound("Dispatch order not found"); + DispatchOrder dispatch = dispatchOrders.lockById(customerId, found.getId()); + OutboundOrder replay = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); + if (replay != null) { + if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch"); + return result(replay, requireDelivery(customerId, replay.getId()), true); + } + OutboundOrder existing = outboundOrders.lockByDispatch(customerId, dispatch.getId()); + if (existing != null) return result(existing, requireDelivery(customerId, existing.getId()), true); + if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) { + throw conflict("Dispatch cannot be transferred to delivery in its current status"); + } + List items = dispatchItems.findByDispatchOrder(customerId, dispatch.getId()); + if (items.isEmpty()) throw conflict("Dispatch has no pallets"); + if (items.stream().anyMatch(item -> item.getWarehousePalletId() == null)) { + throw conflict("All dispatch pallets must be received before transfer to delivery"); + } + if (!StringUtils.hasText(dispatch.getOverseasWarehouseId())) throw conflict("Dispatch warehouse is required"); + + LocalDateTime now = LocalDateTime.now(); + OutboundOrder outbound = new OutboundOrder(); + outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); + outbound.setOutboundNo("OUT" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + outbound.getId()); + outbound.setDispatchOrderId(dispatch.getId()); outbound.setDispatchNo(dispatch.getDispatchNo()); + outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND"); + outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); + outbound.setPalletCount(items.size()); outbound.setIdempotencyKey(request.idempotencyKey().trim()); + outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); + try { + outboundOrders.insert(outbound); + } catch (DuplicateKeyException e) { + // The unique keys are the final concurrent guard. A client retry must observe + // the document created by the winning transaction rather than receive a false failure. + OutboundOrder concurrent = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); + if (concurrent != null && dispatch.getId().equals(concurrent.getDispatchOrderId())) { + return result(concurrent, requireDelivery(customerId, concurrent.getId()), true); + } + concurrent = outboundOrders.lockByDispatch(customerId, dispatch.getId()); + if (concurrent != null) { + return result(concurrent, requireDelivery(customerId, concurrent.getId()), true); + } + throw conflict("Dispatch was concurrently transferred to delivery"); + } + + for (DispatchItem source : items) { + OutboundPalletItem item = new OutboundPalletItem(); + item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId()); + item.setWarehousePalletId(source.getWarehousePalletId()); item.setPalletNo(source.getPalletNo()); + item.setDestinationWarehouseId(source.getDestinationWarehouseId()); + item.setDestinationWarehouseCode(source.getDestinationWarehouseCode()); + item.setDestinationWarehouseName(source.getDestinationWarehouseName()); + item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0)); + item.setOrderRefs(source.getOrderRefs()); item.setInboundCode(source.getInboundCode()); item.setCreatedAt(now); + outboundPallets.insert(item); + } + + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId); + delivery.setDeliveryNo("DEL" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + delivery.getId()); + delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); + delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo()); + delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setStatus("WAIT_DELIVERY"); + delivery.setPackageCount(outbound.getPackageCount()); delivery.setPalletCount(outbound.getPalletCount()); + delivery.setIdempotencyKey(request.idempotencyKey().trim()); delivery.setCreatedBy(operatorId); + delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery); + bindTailOrders(customerId, items, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now); + return result(outbound, delivery, false); + } + + public PageResult outboundPage(String customerId, DeliveryRequests.Query query) { + Page page = outboundOrders.selectPage(new Page<>(query.page(), query.size()), + new LambdaQueryWrapper().eq(OutboundOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.documentNo()), OutboundOrder::getOutboundNo, query.documentNo()) + .eq(StringUtils.hasText(query.warehouseId()), OutboundOrder::getOverseasWarehouseId, query.warehouseId()) + .eq(StringUtils.hasText(query.status()), OutboundOrder::getStatus, query.status()) + .orderByDesc(OutboundOrder::getCreatedAt)); + return page(page, page.getRecords().stream().map(this::outboundRow).toList()); + } + + public DeliveryResponses.OutboundDetail outboundDetail(String customerId, String outboundNo, + DeliveryRequests.PalletQuery query) { + 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())); + } + + public PageResult deliveryPage(String customerId, DeliveryRequests.Query query) { + Page page = deliveryOrders.selectPage(new Page<>(query.page(), query.size()), + new LambdaQueryWrapper().eq(DeliveryOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.documentNo()), DeliveryOrder::getDeliveryNo, query.documentNo()) + .eq(StringUtils.hasText(query.warehouseId()), DeliveryOrder::getOverseasWarehouseId, query.warehouseId()) + .eq(StringUtils.hasText(query.status()), DeliveryOrder::getStatus, query.status()) + .orderByDesc(DeliveryOrder::getCreatedAt)); + return page(page, page.getRecords().stream().map(this::deliveryRow).toList()); + } + + public DeliveryResponses.OutboundDetail deliveryDetail(String customerId, String deliveryNo, + DeliveryRequests.PalletQuery query) { + DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); + if (delivery == null) throw notFound("Delivery order not found"); + OutboundOrder outbound = outboundOrders.selectById(delivery.getOutboundOrderId()); + if (outbound == null || !customerId.equals(outbound.getCustomerId())) throw notFound("Outbound order not found"); + return outboundDetail(customerId, outbound.getOutboundNo(), query); + } + + private void bindTailOrders(String customerId, List items, String outboundNo, String deliveryNo, + String remark, LocalDateTime now) { + Set orderIds = new LinkedHashSet<>(); + items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).forEach(orderIds::add); + List palletIds = items.stream().map(DispatchItem::getPlanPalletId).filter(Objects::nonNull).distinct().toList(); + if (!palletIds.isEmpty()) { + List allocations = planAllocations.selectList( + new LambdaQueryWrapper().in(InboundPlanPalletAllocation::getPalletId, palletIds)); + List lineIds = allocations.stream().map(InboundPlanPalletAllocation::getLineId) + .filter(Objects::nonNull).distinct().toList(); + if (!lineIds.isEmpty()) { + planLines.selectBatchIds(lineIds).stream().map(InboundPlanLine::getOrderId) + .filter(Objects::nonNull).forEach(orderIds::add); + } + } + if (orderIds.isEmpty()) return; + for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, orderIds))) { + String from = order.getCurrentStatus(); order.setOutboundOrderNo(outboundNo); + order.setDeliveryOrderNo(deliveryNo); order.setCurrentStatus("WAIT_OUTBOUND"); order.setUpdatedAt(now); + order.setSubOrderStatus("OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY"); + tailOrders.updateById(order); + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setOrderId(order.getId()); + history.setFromStatus(from); history.setToStatus("WAIT_OUTBOUND"); + history.setEventId("TRANSFER_DELIVERY:" + deliveryNo); + history.setRemark(StringUtils.hasText(remark) ? remark.trim() : "Transferred to outbound and delivery documents"); + history.setCreatedAt(now); histories.insert(history); + } + } + + private DeliveryOrder requireDelivery(String customerId, Long outboundId) { + DeliveryOrder value = deliveryOrders.lockByOutbound(customerId, outboundId); + if (value == null) throw new IllegalStateException("Delivery document is missing for outbound order"); + return value; + } + private DeliveryResponses.TransferResult result(OutboundOrder o, DeliveryOrder d, boolean replayed) { + return new DeliveryResponses.TransferResult(o.getDispatchNo(), o.getOutboundNo(), o.getStatus(), + d.getDeliveryNo(), d.getStatus(), o.getPalletCount(), o.getPackageCount(), replayed); + } + 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/discard/DiscardMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/DiscardMapperConfiguration.java new file mode 100644 index 000000000..bf22c3ae2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/DiscardMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.discard; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.discard.mapper") +public class DiscardMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java new file mode 100644 index 000000000..e0a1a3284 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java @@ -0,0 +1,84 @@ +package com.baiyue.lastleg.discard.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.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.dto.CargoDiscardResponses; +import com.baiyue.lastleg.discard.service.CargoDiscardService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/cargo-discards") +public class CargoDiscardController { + @Resource private CargoDiscardService service; + + @GetMapping("/inventory-candidates") + public ApiResponse> candidates( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @RequestParam(required = false) String inboundCode) { + return ApiResponse.success(service.candidates(scope(user, customerId), inboundCode)); + } + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid @RequestBody CargoDiscardRequests.Create request) { + return ApiResponse.success(service.create(scope(user, customerId), operator(user), request)); + } + + @GetMapping + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid CargoDiscardRequests.Query query) { + return ApiResponse.success(service.page(scope(user, customerId), query)); + } + + @GetMapping("/{discardNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String discardNo) { + return ApiResponse.success(service.detail(scope(user, customerId), discardNo)); + } + + @PostMapping("/{discardNo}/review") + public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String discardNo, + @Valid @RequestBody CargoDiscardRequests.Review request) { + requireApprovalPermission(user); + return ApiResponse.success(service.review(scope(user, customerId), discardNo, operator(user), request)); + } + + private String scope(AuthenticatedUser user, String requested) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requested == null || requested.isBlank() || "0".equals(requested)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); + } + return requested.trim(); + } + return String.valueOf(user.tenantId()); + } + + private void requireApprovalPermission(AuthenticatedUser user) { + if (user == null || user.permissions() == null + || !user.permissions().contains(CargoDiscardService.APPROVAL_PERMISSION)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cargo discard approval permission is required"); + } + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java new file mode 100644 index 000000000..415d0f873 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.discard.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class CargoDiscardRequests { + private CargoDiscardRequests() {} + + public record Create(@NotEmpty @Size(max = 200) List<@Valid Item> items, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record Item(@NotNull Long orderId, @NotBlank @Size(max = 128) String skuCode, + @Positive int discardPackages) {} + + public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {} + + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, + String discardNo, String inboundCode, String overseasWarehouseId, + String status, LocalDateTime createdFrom, LocalDateTime createdTo) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java new file mode 100644 index 000000000..7d906b439 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.discard.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public final class CargoDiscardResponses { + private CargoDiscardResponses() {} + + public record Candidate(Long orderId, String inboundCode, String inboundOrderNo, + String overseasWarehouseId, String overseasWarehouseName, + LocalDateTime inboundCreatedAt, long availablePackages, + List skus) {} + public record CandidateSku(String skuCode, String productName, long availablePackages) {} + + public record Summary(Long id, String discardNo, String inboundCodes, + String overseasWarehouseId, String overseasWarehouseName, + int totalPackages, BigDecimal totalFee, String currency, String status, + String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt) {} + + public record Item(Long id, Long orderId, String inboundCode, String skuCode, + String productName, int availablePackagesSnapshot, int discardPackages) {} + + public record History(String fromStatus, String toStatus, String operatorId, + String remark, LocalDateTime createdAt) {} + + public record Detail(Summary summary, BigDecimal feePerPackage, String remark, + String reviewedBy, String reviewRemark, List items, + List histories) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java new file mode 100644 index 000000000..369ece1f4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.discard.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_cargo_discard_application") +public class CargoDiscardApplication { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private String discardNo; + private String overseasWarehouseId; + private String overseasWarehouseName; + private String inboundCodes; + private Integer totalPackages; + private BigDecimal feePerPackage; + private BigDecimal totalFee; + private String currency; + private String status; + private String remark; + private String idempotencyKey; + private String requestHash; + private String createdBy; + private String reviewedBy; + private String reviewRemark; + private LocalDateTime reviewedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardCarton.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardCarton.java new file mode 100644 index 000000000..b8319c505 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardCarton.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.discard.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_cargo_discard_carton") +public class CargoDiscardCarton { + @TableId(type = IdType.INPUT) private Long id; + private Long applicationId; + private Long itemId; + private String customerId; + private Long cartonId; + private String cartonMark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java new file mode 100644 index 000000000..7912792b2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.discard.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_cargo_discard_item") +public class CargoDiscardItem { + @TableId(type = IdType.INPUT) private Long id; + private Long applicationId; + private String customerId; + private Long orderId; + private String inboundCode; + private Long profileId; + private Long skuInventoryId; + private String skuCode; + private String productName; + private Integer availablePackagesSnapshot; + private Integer discardPackages; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardStatusHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardStatusHistory.java new file mode 100644 index 000000000..e2caa34bc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardStatusHistory.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.discard.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_cargo_discard_status_history") +public class CargoDiscardStatusHistory { + @TableId(type = IdType.INPUT) private Long id; + private Long applicationId; + private String customerId; + private String fromStatus; + private String toStatus; + private String operatorId; + private String remark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java new file mode 100644 index 000000000..64a70ec3e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.discard.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface CargoDiscardApplicationMapper extends BaseMapper { + @Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND discard_no=#{discardNo} LIMIT 1") + CargoDiscardApplication findNo(@Param("customerId") String customerId, @Param("discardNo") String discardNo); + + @Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + CargoDiscardApplication findKey(@Param("customerId") String customerId, @Param("key") String key); + + @Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND discard_no=#{discardNo} LIMIT 1 FOR UPDATE") + CargoDiscardApplication lockNo(@Param("customerId") String customerId, @Param("discardNo") String discardNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardCartonMapper.java new file mode 100644 index 000000000..e672a85ca --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardCartonMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.discard.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.discard.entity.CargoDiscardCarton; +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 CargoDiscardCartonMapper extends BaseMapper { + @Select("SELECT * FROM ll_cargo_discard_carton WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY id") + List listApplication(@Param("customerId") String customerId, + @Param("applicationId") Long applicationId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java new file mode 100644 index 000000000..93a8178cc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java @@ -0,0 +1,69 @@ +package com.baiyue.lastleg.discard.mapper; + +import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +public interface CargoDiscardInventoryMapper { + @Select("") + List candidates(@Param("customerId") String customerId, + @Param("inboundCode") String inboundCode); + + @Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " + + "AND sku_code=#{skuCode} AND status='AVAILABLE'") + long countAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("skuCode") String skuCode); + + @Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " + + "AND profile_id=#{profileId} AND sku_code=#{skuCode} AND status='AVAILABLE'") + long countAvailableProfile(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("profileId") Long profileId, @Param("skuCode") String skuCode); + + @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " + + "AND sku_code=#{skuCode} AND status='AVAILABLE' ORDER BY profile_id,carton_sequence,id LIMIT #{limit} FOR UPDATE") + List lockAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("skuCode") String skuCode, @Param("limit") int limit); + + @Update("UPDATE ll_dropship_carton SET status=#{toStatus},version=version+1,updated_at=NOW(3) " + + "WHERE customer_id=#{customerId} AND id=#{cartonId} AND status=#{fromStatus}") + int changeCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId, + @Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus); + + @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND id=#{cartonId} LIMIT 1 FOR UPDATE") + DropshipCarton lockCarton(@Param("customerId") String customerId, @Param("cartonId") Long cartonId); + + @Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE") + DropshipSkuInventory lockSkuInventory(@Param("customerId") String customerId, @Param("id") Long id); + + @Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND order_id=#{orderId} " + + "AND profile_id=#{profileId} AND sku_code=#{skuCode} LIMIT 1 FOR UPDATE") + DropshipSkuInventory lockSkuInventoryDimension(@Param("customerId") String customerId, + @Param("orderId") Long orderId, + @Param("profileId") Long profileId, + @Param("skuCode") String skuCode); + + @Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count-#{packages},updated_at=NOW(3) " + + "WHERE customer_id=#{customerId} AND id=#{id} AND package_count>=#{packages}") + int deductSkuInventory(@Param("customerId") String customerId, @Param("id") Long id, + @Param("packages") int packages); + + @Select("SELECT package_count FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND id=#{id}") + Long skuPackages(@Param("customerId") String customerId, @Param("id") Long id); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardItemMapper.java new file mode 100644 index 000000000..19ea10c5c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardItemMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.discard.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +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 CargoDiscardItemMapper extends BaseMapper { + @Select("SELECT * FROM ll_cargo_discard_item WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY inbound_code,sku_code,profile_id") + List listApplication(@Param("customerId") String customerId, + @Param("applicationId") Long applicationId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardStatusHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardStatusHistoryMapper.java new file mode 100644 index 000000000..816a41e29 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardStatusHistoryMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.discard.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.discard.entity.CargoDiscardStatusHistory; +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 CargoDiscardStatusHistoryMapper extends BaseMapper { + @Select("SELECT * FROM ll_cargo_discard_status_history WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY created_at,id") + List listApplication(@Param("customerId") String customerId, + @Param("applicationId") Long applicationId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardCandidateRow.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardCandidateRow.java new file mode 100644 index 000000000..90abc3e1b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardCandidateRow.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.discard.model; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class CargoDiscardCandidateRow { + private Long orderId; + private String inboundCode; + private String inboundOrderNo; + private String overseasWarehouseId; + private String overseasWarehouseName; + private LocalDateTime createdAt; + private Long profileId; + private Long skuInventoryId; + private String skuCode; + private String productName; + private Long availablePackages; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java new file mode 100644 index 000000000..1691f7dda --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java @@ -0,0 +1,98 @@ +package com.baiyue.lastleg.discard.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.entity.CargoDiscardCarton; +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** Final inventory mutation for a warehouse-approved cargo discard. */ +@Service +public class CargoDiscardInventoryAdjustmentService { + @Resource private CargoDiscardInventoryMapper inventory; + @Resource private DropshipCartonLedgerMapper cartonLedgers; + + @Transactional(propagation = Propagation.MANDATORY) + public AdjustmentResult discard(String customerId, CargoDiscardApplication application, + List items, + Map> linksByItem, + String operatorId, LocalDateTime occurredAt) { + long beforePackages = 0; + long discardedPackages = 0; + long afterPackages = 0; + for (CargoDiscardItem item : items) { + DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId()); + if (sku == null) throw conflict("SKU inventory is missing during discard approval: " + item.getSkuCode()); + long before = value(sku.getPackageCount()); + int discarded = item.getDiscardPackages(); + if (before < discarded || inventory.deductSkuInventory(customerId, sku.getId(), discarded) != 1) { + throw conflict("SKU inventory is insufficient during discard approval: " + item.getSkuCode()); + } + List links = linksByItem.getOrDefault(item.getId(), List.of()); + if (links.size() != discarded) throw conflict("Reserved discard cartons are incomplete"); + for (CargoDiscardCarton link : links) { + DropshipCarton carton = inventory.lockCarton(customerId, link.getCartonId()); + if (carton == null || inventory.changeCartonStatus(customerId, carton.getId(), + "DISCARD_PENDING", "DISCARDED") != 1) throw conflict("Reserved cargo status changed before approval"); + cartonLedgers.insert(discardFact(customerId, application, item, carton, operatorId, occurredAt)); + } + Long persistedAfter = inventory.skuPackages(customerId, sku.getId()); + long expectedAfter = before - discarded; + if (persistedAfter == null || persistedAfter != expectedAfter) { + throw conflict("Cargo discard package conservation check failed for SKU " + item.getSkuCode()); + } + beforePackages = Math.addExact(beforePackages, before); + discardedPackages = Math.addExact(discardedPackages, discarded); + afterPackages = Math.addExact(afterPackages, persistedAfter); + } + if (beforePackages - discardedPackages != afterPackages + || discardedPackages != application.getTotalPackages()) { + throw conflict("Cargo discard application package conservation check failed"); + } + return new AdjustmentResult(beforePackages, discardedPackages, afterPackages); + } + + private DropshipCartonLedger discardFact(String customerId, CargoDiscardApplication application, + CargoDiscardItem item, DropshipCarton carton, + String operatorId, LocalDateTime occurredAt) { + DropshipCartonLedger fact = new DropshipCartonLedger(); + fact.setId(IdWorker.getId()); fact.setCustomerId(customerId); fact.setOrderId(item.getOrderId()); + fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(item.getSkuCode()); + fact.setOperationType("DISCARD"); fact.setBeforePackages(1L); fact.setDeltaPackages(-1L); fact.setAfterPackages(0L); + fact.setBusinessOrderNo(application.getDiscardNo()); + fact.setIdempotencyKey("CARGO_DISCARD:" + application.getId() + ":" + carton.getId()); + fact.setRemark("Warehouse discard approved by " + operatorId + remarkSuffix(application.getRemark())); + fact.setOccurredAt(occurredAt); fact.setCreatedAt(occurredAt); + return fact; + } + + private static long value(Long value) { return value == null ? 0L : value; } + private static String remarkSuffix(String value) { + return value == null || value.isBlank() ? "" : "; " + value.trim(); + } + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } + + public record AdjustmentResult(long beforePackages, long discardedPackages, long afterPackages) { + public String auditRemark(String reviewRemark) { + String prefix = reviewRemark == null || reviewRemark.isBlank() ? "" : reviewRemark.trim() + "; "; + return prefix + "inventoryBefore=" + beforePackages + ", discarded=" + discardedPackages + + ", inventoryAfter=" + afterPackages; + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java new file mode 100644 index 000000000..2f366c605 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java @@ -0,0 +1,303 @@ +package com.baiyue.lastleg.discard.service; + +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.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.dto.CargoDiscardResponses; +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.entity.CargoDiscardCarton; +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import com.baiyue.lastleg.discard.entity.CargoDiscardStatusHistory; +import com.baiyue.lastleg.discard.mapper.CargoDiscardApplicationMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardCartonMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper; +import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +public class CargoDiscardService { + public static final String APPROVAL_PERMISSION = "lastleg:cargo-discard:approve"; + 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 CargoDiscardApplicationMapper applications; + @Resource private CargoDiscardItemMapper items; + @Resource private CargoDiscardCartonMapper discardCartons; + @Resource private CargoDiscardStatusHistoryMapper histories; + @Resource private CargoDiscardInventoryMapper inventory; + @Resource private CargoDiscardInventoryAdjustmentService adjustments; + @Resource private LastlegOrderMapper orders; + @Resource private ObjectMapper objectMapper; + @Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}") + private BigDecimal feePerPackage = new BigDecimal("5.00"); + + public List candidates(String customerId, String inboundCode) { + List rows = inventory.candidates(customerId, trim(inboundCode)); + Map grouped = new LinkedHashMap<>(); + for (CargoDiscardCandidateRow row : rows) { + CandidateAccumulator value = grouped.computeIfAbsent(row.getOrderId(), ignored -> new CandidateAccumulator(row)); + value.add(row); + } + return grouped.values().stream().map(CandidateAccumulator::response).toList(); + } + + @Transactional + public CargoDiscardResponses.Detail create(String customerId, String operatorId, + CargoDiscardRequests.Create request) { + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + CargoDiscardApplication replay = applications.findKey(customerId, key); + if (replay != null) { + if (!requestHash.equals(replay.getRequestHash())) { + throw conflict("Idempotency key was reused with different cargo discard data"); + } + return detail(customerId, replay.getDiscardNo()); + } + Set requestedDimensions = new LinkedHashSet<>(); + Map orderCache = new HashMap<>(); + String warehouseId = null; + String warehouseName = null; + int total = 0; + for (CargoDiscardRequests.Item input : request.items()) { + String dimension = input.orderId() + ":" + input.skuCode().trim(); + if (!requestedDimensions.add(dimension)) throw badRequest("Duplicate order/SKU discard item: " + dimension); + LastlegOrder order = requireDropshipOrder(customerId, input.orderId()); + orderCache.put(order.getId(), order); + if (warehouseId == null) { + warehouseId = required(order.getOverseasWarehouseId(), "Order has no operating overseas warehouse"); + warehouseName = trim(order.getOverseasWarehouseName()); + } else if (!warehouseId.equals(order.getOverseasWarehouseId())) { + throw conflict("One cargo discard application cannot span operating warehouses"); + } + total = Math.addExact(total, input.discardPackages()); + } + + LocalDateTime now = LocalDateTime.now(); + CargoDiscardApplication application = new CargoDiscardApplication(); + application.setId(IdWorker.getId()); application.setCustomerId(customerId); + application.setDiscardNo("FH" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + application.getId()); + application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName); + application.setInboundCodes(orderCache.values().stream().map(LastlegOrder::getInboundCode) + .distinct().sorted().collect(java.util.stream.Collectors.joining(","))); + application.setTotalPackages(total); application.setFeePerPackage(feePerPackage); + application.setTotalFee(feePerPackage.multiply(BigDecimal.valueOf(total))); + application.setCurrency("USD"); application.setStatus(PENDING_REVIEW); + application.setRemark(trim(request.remark())); application.setIdempotencyKey(key); + application.setRequestHash(requestHash); application.setCreatedBy(operatorId); + application.setCreatedAt(now); application.setUpdatedAt(now); + applications.insert(application); + + for (CargoDiscardRequests.Item input : request.items()) { + reserve(customerId, application, orderCache.get(input.orderId()), input, now); + } + history(customerId, application.getId(), null, PENDING_REVIEW, operatorId, request.remark(), now); + return detail(customerId, application.getDiscardNo()); + } + + public PageResult page(String customerId, CargoDiscardRequests.Query query) { + validateQuery(query); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(CargoDiscardApplication::getCustomerId, customerId) + .like(StringUtils.hasText(query.discardNo()), CargoDiscardApplication::getDiscardNo, query.discardNo()) + .like(StringUtils.hasText(query.inboundCode()), CargoDiscardApplication::getInboundCodes, query.inboundCode()) + .eq(StringUtils.hasText(query.overseasWarehouseId()), CargoDiscardApplication::getOverseasWarehouseId, + query.overseasWarehouseId()) + .eq(StringUtils.hasText(query.status()), CargoDiscardApplication::getStatus, query.status()) + .ge(query.createdFrom() != null, CargoDiscardApplication::getCreatedAt, query.createdFrom()) + .le(query.createdTo() != null, CargoDiscardApplication::getCreatedAt, query.createdTo()) + .orderByDesc(CargoDiscardApplication::getCreatedAt).orderByDesc(CargoDiscardApplication::getId); + Page result = applications.selectPage(Page.of(query.page(), query.size()), wrapper); + List records = result.getRecords().stream().map(this::summary).toList(); + return new PageResult<>(records, query.page(), query.size(), result.getTotal(), result.getPages()); + } + + public CargoDiscardResponses.Detail detail(String customerId, String discardNo) { + CargoDiscardApplication application = applications.findNo(customerId, discardNo); + if (application == null) throw notFound("Cargo discard application not found"); + List itemViews = items.listApplication(customerId, application.getId()).stream() + .map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(), + value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages())) + .toList(); + List historyViews = histories.listApplication(customerId, application.getId()).stream() + .map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(), + value.getOperatorId(), value.getRemark(), value.getCreatedAt())).toList(); + return new CargoDiscardResponses.Detail(summary(application), application.getFeePerPackage(), + application.getRemark(), application.getReviewedBy(), application.getReviewRemark(), itemViews, historyViews); + } + + @Transactional + public CargoDiscardResponses.Detail review(String customerId, String discardNo, String operatorId, + CargoDiscardRequests.Review request) { + String decision = request.decision().trim().toUpperCase(); + String next = switch (decision) { + case "APPROVE", APPROVED -> APPROVED; + case "REJECT", REJECTED -> REJECTED; + default -> throw badRequest("Review decision must be APPROVE or REJECT"); + }; + CargoDiscardApplication application = applications.lockNo(customerId, discardNo); + if (application == null) throw notFound("Cargo discard application not found"); + if (next.equals(application.getStatus())) return detail(customerId, discardNo); + if (!PENDING_REVIEW.equals(application.getStatus())) throw conflict("Cargo discard application was already reviewed"); + + List applicationItems = items.listApplication(customerId, application.getId()); + List links = discardCartons.listApplication(customerId, application.getId()); + Map> linksByItem = links.stream() + .collect(java.util.stream.Collectors.groupingBy(CargoDiscardCarton::getItemId)); + String historyRemark = request.remark(); + if (APPROVED.equals(next)) { + CargoDiscardInventoryAdjustmentService.AdjustmentResult result = adjustments.discard( + customerId, application, applicationItems, linksByItem, operatorId, now()); + historyRemark = result.auditRemark(request.remark()); + } else { + release(customerId, links); + } + + LocalDateTime reviewedAt = now(); + application.setStatus(next); application.setReviewedBy(operatorId); + application.setReviewRemark(trim(request.remark())); application.setReviewedAt(reviewedAt); + application.setUpdatedAt(reviewedAt); applications.updateById(application); + history(customerId, application.getId(), PENDING_REVIEW, next, operatorId, historyRemark, reviewedAt); + return detail(customerId, discardNo); + } + + private void reserve(String customerId, CargoDiscardApplication application, LastlegOrder order, + CargoDiscardRequests.Item input, LocalDateTime now) { + String skuCode = input.skuCode().trim(); + if (inventory.countAvailable(customerId, order.getId(), skuCode) < input.discardPackages()) { + throw conflict("Insufficient available packages for SKU " + skuCode + " on inbound " + order.getInboundCode()); + } + List selected = inventory.lockAvailable(customerId, order.getId(), skuCode, input.discardPackages()); + if (selected.size() != input.discardPackages()) throw conflict("Available cargo changed; retry the discard request"); + Map> byProfile = selected.stream() + .collect(java.util.stream.Collectors.groupingBy(DropshipCarton::getProfileId, LinkedHashMap::new, + java.util.stream.Collectors.toList())); + for (Map.Entry> entry : byProfile.entrySet()) { + DropshipSkuInventory sku = inventory.lockSkuInventoryDimension(customerId, order.getId(), entry.getKey(), skuCode); + if (sku == null) throw conflict("SKU inventory disappeared while reserving cargo"); + CargoDiscardItem item = new CargoDiscardItem(); + item.setId(IdWorker.getId()); item.setApplicationId(application.getId()); item.setCustomerId(customerId); + item.setOrderId(order.getId()); item.setInboundCode(order.getInboundCode()); item.setProfileId(entry.getKey()); + item.setSkuInventoryId(sku.getId()); item.setSkuCode(skuCode); item.setProductName(sku.getProductName()); + item.setAvailablePackagesSnapshot(Math.toIntExact(inventory.countAvailableProfile( + customerId, order.getId(), entry.getKey(), skuCode))); + item.setDiscardPackages(entry.getValue().size()); item.setCreatedAt(now); items.insert(item); + for (DropshipCarton carton : entry.getValue()) { + if (inventory.changeCartonStatus(customerId, carton.getId(), "AVAILABLE", "DISCARD_PENDING") != 1) { + throw conflict("Cargo was reserved concurrently; retry the discard request"); + } + CargoDiscardCarton link = new CargoDiscardCarton(); + link.setId(IdWorker.getId()); link.setApplicationId(application.getId()); link.setItemId(item.getId()); + link.setCustomerId(customerId); link.setCartonId(carton.getId()); link.setCartonMark(carton.getCartonMark()); + link.setCreatedAt(now); discardCartons.insert(link); + } + } + } + + private void release(String customerId, List links) { + for (CargoDiscardCarton link : links) { + if (inventory.changeCartonStatus(customerId, link.getCartonId(), "DISCARD_PENDING", "AVAILABLE") != 1) { + throw conflict("Reserved cargo status changed before rejection"); + } + } + } + + private LastlegOrder requireDropshipOrder(String customerId, Long orderId) { + LastlegOrder order = orders.selectById(orderId); + if (order == null || !customerId.equals(order.getCustomerId())) throw notFound("Inbound order not found"); + if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) { + throw conflict("Cargo discard currently requires SKU/carton inventory from a one-click dropship order"); + } + return order; + } + + private CargoDiscardResponses.Summary summary(CargoDiscardApplication value) { + return new CargoDiscardResponses.Summary(value.getId(), value.getDiscardNo(), value.getInboundCodes(), + value.getOverseasWarehouseId(), value.getOverseasWarehouseName(), value.getTotalPackages(), + value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(), + value.getReviewedAt()); + } + + private void history(String customerId, Long applicationId, String from, String to, String operatorId, + String remark, LocalDateTime createdAt) { + CargoDiscardStatusHistory value = new CargoDiscardStatusHistory(); + value.setId(IdWorker.getId()); value.setApplicationId(applicationId); value.setCustomerId(customerId); + value.setFromStatus(from); value.setToStatus(to); value.setOperatorId(operatorId); + value.setRemark(trim(remark)); value.setCreatedAt(createdAt); histories.insert(value); + } + + private void validateQuery(CargoDiscardRequests.Query query) { + if (StringUtils.hasText(query.status()) && !STATUSES.contains(query.status())) throw badRequest("Unsupported discard status"); + if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { + throw badRequest("createdFrom must not be after createdTo"); + } + } + + private String hash(Object value) { + try { + byte[] json = objectMapper.writeValueAsBytes(value); + return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json)); + } catch (JsonProcessingException | NoSuchAlgorithmException exception) { + throw new IllegalStateException("Cannot hash cargo discard request", exception); + } + } + + private static LocalDateTime now() { return LocalDateTime.now(); } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String required(String value, String message) { String result = trim(value); if (result == null) throw conflict(message); return result; } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + 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); } + + private static final class CandidateAccumulator { + private final CargoDiscardCandidateRow first; + private final Map skus = new LinkedHashMap<>(); + private CandidateAccumulator(CargoDiscardCandidateRow first) { this.first = first; } + private void add(CargoDiscardCandidateRow row) { + skus.computeIfAbsent(row.getSkuCode(), ignored -> new CandidateSkuAccumulator(row.getSkuCode(), row.getProductName())) + .packages += row.getAvailablePackages() == null ? 0 : row.getAvailablePackages(); + } + private CargoDiscardResponses.Candidate response() { + List skuViews = skus.values().stream() + .map(value -> new CargoDiscardResponses.CandidateSku(value.skuCode, value.productName, value.packages)).toList(); + long packages = skuViews.stream().mapToLong(CargoDiscardResponses.CandidateSku::availablePackages).sum(); + return new CargoDiscardResponses.Candidate(first.getOrderId(), first.getInboundCode(), first.getInboundOrderNo(), + first.getOverseasWarehouseId(), first.getOverseasWarehouseName(), first.getCreatedAt(), packages, skuViews); + } + } + private static final class CandidateSkuAccumulator { + private final String skuCode; + private final String productName; + private long packages; + private CandidateSkuAccumulator(String skuCode, String productName) { this.skuCode = skuCode; this.productName = productName; } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/DispatchMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/DispatchMapperConfiguration.java new file mode 100644 index 000000000..9a4339977 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/DispatchMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.dispatch; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.dispatch.mapper") +public class DispatchMapperConfiguration { +} 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 new file mode 100644 index 000000000..9cf6b7f44 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java @@ -0,0 +1,134 @@ +package com.baiyue.lastleg.dispatch.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.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; +import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController; +import com.baiyue.lastleg.dispatch.service.DispatchService; +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.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/dispatches") +@Tag(name = "Tail-leg vehicle dispatch") +public class DispatchController { + @Resource private DispatchService service; + @Resource private DispatchPdaWorkflowService pdaWorkflow; + + @GetMapping("/candidates") + @Operation(summary = "List unlocked planned or received pallets for intelligent pre-dispatch") + public ApiResponse> candidates( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @RequestParam(required = false) String orderNo, + @RequestParam(required = false) String fbaWarehouseCode, + @RequestParam(required = false) String sourceStage) { + return ApiResponse.success(service.candidates(scope(user, customerId), orderNo, fbaWarehouseCode, sourceStage)); + } + + @PostMapping("/pre-arrange") + @Operation(summary = "Create a pending-departure dispatch and lock all selected pallets") + public ApiResponse preArrange( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid @RequestBody DispatchRequests.PreArrange request) { + String scopedTenant = scope(user, customerId); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(service.preArrange(scopedTenant, operatorId, request)); + } + + @GetMapping + @Operation(summary = "Page dispatch orders") + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid DispatchRequests.Query query) { + return ApiResponse.success(service.page(scope(user, customerId), query)); + } + + @GetMapping("/{dispatchNo}") + @Operation(summary = "Get dispatch details and all pallet destinations") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable String dispatchNo) { + return ApiResponse.success(service.detail(scope(user, customerId), dispatchNo)); + } + + @PutMapping("/{dispatchNo}") + @Operation(summary = "Edit vehicle, driver, appointment, route and departure data") + public ApiResponse update( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable String dispatchNo, + @Valid @RequestBody DispatchRequests.Update request) { + return ApiResponse.success(service.update(scope(user, customerId), dispatchNo, request)); + } + + @DeleteMapping("/{dispatchNo}") + @Operation(summary = "Cancel a pending dispatch and release its pallet locks") + public ApiResponse cancel( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable String dispatchNo) { + service.cancel(scope(user, customerId), dispatchNo); + return ApiResponse.success(null); + } + + @PostMapping("/{dispatchNo}/depart") + @Operation(summary = "Ship all PDA-picked pallets and mark the dispatch departed") + public ApiResponse depart( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable String dispatchNo, + @Valid @RequestBody DispatchPdaRequests.Depart request) { + requirePdaOperate(user); + String scopedTenant = scope(user, customerId); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(pdaWorkflow.depart(scopedTenant, dispatchNo, operatorId, request)); + } + + private String scope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Platform request must specify target tenant"); + } + return requestedCustomer; + } + return String.valueOf(user.tenantId()); + } + + private void requirePdaOperate(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (user.permissions() == null || !user.permissions().contains(DispatchPdaController.OPERATE_PERMISSION)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + DispatchPdaController.OPERATE_PERMISSION + " permission is required"); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java new file mode 100644 index 000000000..370d81171 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.dispatch.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class DispatchRequests { + private DispatchRequests() { + } + + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String dispatchNo, + String vehiclePlate, String status, String fbaAppointmentNo) { + public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record PreArrange(@NotEmpty @Size(max = 500) List<@NotNull Long> planPalletIds, + @Size(max = 255) String route, @Size(max = 64) String fbaAppointmentNo, + @Size(max = 32) String fbaAppointmentStatus, + @Size(max = 32) String vehicleAppointmentStatus) { + } + + public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName, + @Size(max = 64) String driverPhone, @Size(max = 255) String carrierName, + @Size(max = 255) String route, LocalDateTime departAt, + @Size(max = 64) String fbaAppointmentNo, + @Size(max = 32) String fbaAppointmentStatus, + @Size(max = 32) String vehicleAppointmentStatus, + LocalDateTime estimatedArrivalAt, @Size(max = 32) String status) { + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java new file mode 100644 index 000000000..5e7d2a81d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.dispatch.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate, String driverName, + String driverPhone, String carrierName, String orderNos, String route, + String overseasWarehouseId, String overseasWarehouseName, LocalDateTime departAt, + String fbaAppointmentNo, String fbaAppointmentStatus, + String vehicleAppointmentStatus, LocalDateTime estimatedArrivalAt, String status, + int itemCount, int palletCount, int packageCount, int pickedPackageCount, + int shippedPackageCount, int remainingPackageCount, String partialCompletionReason, + LocalDateTime createdAt, + List items) { + public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo, + String overseasWarehouseId, String overseasWarehouseName, + String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode, + String destinationWarehouseName, String fbaWarehouseCode, int packageCount, + BigDecimal volumeCbm, String sourceStage) { + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java new file mode 100644 index 000000000..2fa8e73d0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.dispatch.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** Immutable pallet/cargo snapshot selected into a dispatch order. */ +@Data +@TableName("ll_dispatch_item") +public class DispatchItem { + @TableId(type = IdType.INPUT) + private Long id; + private Long dispatchOrderId; + private String customerId; + private Long planId; + private Long planPalletId; + private Long warehousePalletId; + private Long orderId; + private String overseasWarehouseId; + private String overseasWarehouseName; + private String orderNo; + private String orderRefs; + private String inboundCode; + private String palletNo; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private String fbaWarehouseCode; + private Integer packageCount; + private BigDecimal volumeCbm; + /** PLAN for planned pallets; RECEIVED for warehouse/entity pallets. */ + private String sourceStage; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchOrder.java new file mode 100644 index 000000000..78caeb77d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchOrder.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.dispatch.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** Header for a pre-dispatched vehicle load. Vehicle information remains editable before departure. */ +@Data +@TableName("ll_dispatch_order") +public class DispatchOrder { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private String dispatchNo; + private String overseasWarehouseId; + private String overseasWarehouseName; + private String vehiclePlate; + private String driverName; + private String driverPhone; + private String carrierName; + private String route; + private LocalDateTime departAt; + private String fbaAppointmentNo; + private String fbaAppointmentStatus; + private String vehicleAppointmentStatus; + private LocalDateTime estimatedArrivalAt; + private String status; + private Integer itemCount; + private Integer palletCount; + private Integer packageCount; + private Integer pickedPackageCount; + private Integer shippedPackageCount; + private String partialCompletionReason; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java new file mode 100644 index 000000000..386d77e17 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.dispatch.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** Prevents a source pallet from being added to more than one active dispatch order. */ +@Data +@TableName("ll_dispatch_pallet_lock") +public class DispatchPalletLock { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private String sourcePalletKey; + /** Equals sourcePalletKey while ACTIVE and is null after release; unique index enforces exclusivity. */ + private String activeKey; + private Long planId; + private Long planPalletId; + private Long warehousePalletId; + private Long dispatchOrderId; + private String status; + private LocalDateTime lockedAt; + private LocalDateTime releasedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java new file mode 100644 index 000000000..2d0b37abc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -0,0 +1,78 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +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 DispatchCandidateMapper { + @Select("") + List available(@Param("customerId") String customerId, + @Param("orderNo") String orderNo, + @Param("fbaWarehouseCode") String fbaWarehouseCode, + @Param("sourceStage") String sourceStage); + + @Select("") + List lockSelected(@Param("customerId") String customerId, + @Param("planPalletIds") List planPalletIds); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java new file mode 100644 index 000000000..2dff34801 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +public interface DispatchItemMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_item WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} ORDER BY id") + List findByDispatchOrder(@Param("customerId") String customerId, + @Param("dispatchOrderId") Long dispatchOrderId); + + @Update("UPDATE ll_dispatch_item i JOIN ll_inbound_plan_pallet p ON p.id=i.plan_pallet_id " + + "JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=i.customer_id " + + "SET i.warehouse_pallet_id=p.warehouse_pallet_id,i.source_stage='RECEIVED' " + + "WHERE i.customer_id=#{customerId} AND i.dispatch_order_id=#{dispatchOrderId} " + + "AND i.warehouse_pallet_id IS NULL AND p.warehouse_pallet_id IS NOT NULL") + int bindReceivedPallets(@Param("customerId") String customerId, + @Param("dispatchOrderId") Long dispatchOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java new file mode 100644 index 000000000..2a46cf219 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface DispatchOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND dispatch_no=#{dispatchNo} LIMIT 1") + DispatchOrder findByDispatchNo(@Param("customerId") String customerId, @Param("dispatchNo") String dispatchNo); + + @Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE") + DispatchOrder lockById(@Param("customerId") String customerId, @Param("id") Long id); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java new file mode 100644 index 000000000..ba5a4e5e4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface DispatchPalletLockMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} AND source_pallet_key=#{sourcePalletKey} " + + "AND status='ACTIVE' LIMIT 1 FOR UPDATE") + DispatchPalletLock lockActive(@Param("customerId") String customerId, + @Param("sourcePalletKey") String sourcePalletKey); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java new file mode 100644 index 000000000..27c479c28 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.dispatch.model; + +import java.math.BigDecimal; + +public record DispatchPalletCandidate( + Long planId, + Long planPalletId, + Long warehousePalletId, + Long orderId, + String overseasWarehouseId, + String overseasWarehouseName, + String orderNo, + String orderRefs, + String inboundCode, + String palletNo, + String destinationWarehouseId, + String destinationWarehouseCode, + String destinationWarehouseName, + String fbaWarehouseCode, + Integer packageCount, + BigDecimal volumeCbm, + String sourceStage +) { + public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId, + String orderNo, String orderRefs, String inboundCode, String palletNo, + String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, String fbaWarehouseCode, Integer packageCount, + BigDecimal volumeCbm, String sourceStage) { + this(planId, planPalletId, warehousePalletId, orderId, null, null, orderNo, orderRefs, inboundCode, + palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, + fbaWarehouseCode, packageCount, volumeCbm, sourceStage); + } + public String sourcePalletKey() { + // A plan pallet keeps the same lock identity before and after the physical pallet is received. + return planPalletId != null ? "PLAN:" + planPalletId : "RECEIVED:" + warehousePalletId; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/DispatchPdaMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/DispatchPdaMapperConfiguration.java new file mode 100644 index 000000000..ddfbe6b50 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/DispatchPdaMapperConfiguration.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.dispatch.pda; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.dispatch.pda.mapper") +public class DispatchPdaMapperConfiguration {} 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 new file mode 100644 index 000000000..e45211b0b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java @@ -0,0 +1,69 @@ +package com.baiyue.lastleg.dispatch.pda.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; +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.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") +public class DispatchPdaController { + public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; + @Resource private DispatchPdaWorkflowService service; + + @PostMapping("/push") + public ApiResponse push(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, + @RequestParam(required = false) String customerId, + @Valid @RequestBody DispatchPdaRequests.Push request) { + requireOperate(user); + return ApiResponse.success(service.push(scope(user, customerId), dispatchNo, request)); + } + @GetMapping + public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, + @RequestParam(required = false) String customerId) { + return ApiResponse.success(service.task(scope(user, customerId), dispatchNo)); + } + @PostMapping("/items/{itemId}/pick") + public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, @PathVariable long itemId, + @RequestParam(required = false) String customerId, + @Valid @RequestBody DispatchPdaRequests.Pick request) { + requireOperate(user); + return ApiResponse.success(service.pick(scope(user, customerId), dispatchNo, itemId, operator(user), request)); + } + @PostMapping("/complete") + public ApiResponse completePicking( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, + @RequestParam(required = false) String customerId, + @Valid @RequestBody DispatchPdaRequests.CompletePicking request) { + requireOperate(user); + return ApiResponse.success(service.completePicking(scope(user, customerId), dispatchNo, request)); + } + private String scope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); + } + return requestedCustomer.trim(); + } + return String.valueOf(user.tenantId()); + } + private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } + private void requireOperate(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required"); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java new file mode 100644 index 000000000..e98c0cdcc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.dispatch.pda.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +public final class DispatchPdaRequests { + private DispatchPdaRequests() {} + public record Push(@NotBlank String idempotencyKey, String operatorId, String operatorName, + String warehouseId) { + public Push(String idempotencyKey, String operatorId, String operatorName) { + this(idempotencyKey, operatorId, operatorName, null); + } + } + public record Pick(@NotNull Long positionId, @Positive long packages, @PositiveOrZero long productQuantity, + @NotBlank String idempotencyKey, String remark, String warehouseId) { + public Pick(Long positionId, long packages, long productQuantity, String idempotencyKey, String remark) { + this(positionId, packages, productQuantity, idempotencyKey, remark, null); + } + } + public record Depart(@NotBlank String idempotencyKey, String remark) {} + public record CompletePicking(@NotBlank String idempotencyKey, + @NotBlank @Size(max = 1000) String reason) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java new file mode 100644 index 000000000..638d51be2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.dispatch.pda.dto; + +import java.util.List; + +public final class DispatchPdaResponses { + private DispatchPdaResponses() {} + public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String warehouseId, String status, + List items) {} + public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode, + String destinationWarehouseName, String fbaWarehouseCode, int scheduledPackages, + long pickedPackages, List positions) {} + public record Position(Long id, Long balanceId, long availablePackages, long allocatedPackages, + long pickedPackages) {} + public record Picked(Long dispatchItemId, Long positionId, long packages, long pickedPackages, + boolean palletScrapped, boolean replayed) {} + public record Departed(String dispatchNo, String status, long shippedPackages, boolean replayed) { + public Departed withReplay() { return new Departed(dispatchNo, status, shippedPackages, true); } + } + public record PickingCompleted(String dispatchNo, String status, long plannedPackages, + long pickedPackages, long remainingPackages, boolean partial, + boolean replayed) { + public PickingCompleted withReplay() { + return new PickingCompleted(dispatchNo, status, plannedPackages, pickedPackages, + remainingPackages, partial, true); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaOperation.java new file mode 100644 index 000000000..94637d9a8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaOperation.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dispatch.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_dispatch_pda_operation") +public class DispatchPdaOperation { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long taskId; + private String operationType; + private String idempotencyKey; + private String requestHash; + private String resultJson; + private String status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java new file mode 100644 index 000000000..fe47e73c9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dispatch.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_dispatch_pda_pick") +public class DispatchPdaPick { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long taskId; + private Long dispatchItemId; + private Long inventoryPositionId; + private Long packages; + private Long productQuantity; + private String idempotencyKey; + private LocalDateTime pickedAt; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java new file mode 100644 index 000000000..4f87b9f53 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.dispatch.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** Immutable FIFO attribution of a PDA pick to its inbound receipt and tail-leg order. */ +@Data +@TableName("ll_dispatch_pda_pick_order") +public class DispatchPdaPickOrder { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long pickId; + private Long receiptId; + private Long orderId; + private Long packages; + private Long productQuantity; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java new file mode 100644 index 000000000..81ccee623 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.dispatch.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_dispatch_pda_task") +public class DispatchPdaTask { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long dispatchOrderId; + private String taskNo; + private String status; + private String operatorId; + private String operatorName; + private String warehouseId; + private LocalDateTime pushedAt; + private LocalDateTime completedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaOperationMapper.java new file mode 100644 index 000000000..a94613689 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaOperationMapper.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.dispatch.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaOperation; +import org.apache.ibatis.annotations.*; + +@Mapper +public interface DispatchPdaOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_pda_operation WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + DispatchPdaOperation findKey(@Param("customerId") String customerId, @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java new file mode 100644 index 000000000..2b8c0f33c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.dispatch.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface DispatchPdaPickMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_pda_pick WHERE customer_id=#{customerId} AND task_id=#{taskId} ORDER BY id") + List listTask(@Param("customerId") String customerId, @Param("taskId") Long taskId); + @Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick WHERE customer_id=#{customerId} AND task_id=#{taskId} AND dispatch_item_id=#{itemId}") + long totalItem(@Param("customerId") String customerId, @Param("taskId") Long taskId, @Param("itemId") Long itemId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java new file mode 100644 index 000000000..c14b0f8a9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.dispatch.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPickOrder; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +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 DispatchPdaPickOrderMapper extends BaseMapper { + @Select("SELECT r.* FROM ll_inbound_stock_receipt r " + + "WHERE r.customer_id=#{customerId} AND r.inventory_position_id=#{positionId} " + + "AND r.received_packages > (SELECT COALESCE(SUM(a.packages),0) FROM ll_dispatch_pda_pick_order a " + + "WHERE a.customer_id=r.customer_id AND a.receipt_id=r.id) ORDER BY r.received_at,r.id") + List availableReceipts(@Param("customerId") String customerId, + @Param("positionId") Long positionId); + + @Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick_order " + + "WHERE customer_id=#{customerId} AND receipt_id=#{receiptId}") + long attributedPackages(@Param("customerId") String customerId, @Param("receiptId") Long receiptId); + + @Select("SELECT COALESCE(SUM(product_quantity),0) FROM ll_dispatch_pda_pick_order " + + "WHERE customer_id=#{customerId} AND receipt_id=#{receiptId}") + long attributedQuantity(@Param("customerId") String customerId, @Param("receiptId") Long receiptId); + + @Select("SELECT * FROM ll_dispatch_pda_pick_order WHERE customer_id=#{customerId} AND pick_id=#{pickId} ORDER BY id") + List listPick(@Param("customerId") String customerId, @Param("pickId") Long pickId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java new file mode 100644 index 000000000..6c548691e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.dispatch.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; +import org.apache.ibatis.annotations.*; + +@Mapper +public interface DispatchPdaTaskMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE") + DispatchPdaTask lockByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId); + + @Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1") + DispatchPdaTask findByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java new file mode 100644 index 000000000..e205489aa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -0,0 +1,277 @@ +package com.baiyue.lastleg.dispatch.pda.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; +import com.baiyue.lastleg.dispatch.pda.entity.*; +import com.baiyue.lastleg.dispatch.pda.mapper.*; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class DispatchPdaWorkflowService { + @Resource private DispatchOrderMapper orders; + @Resource private OutboundOrderMapper outboundOrders; + @Resource private DispatchItemMapper items; + @Resource private DispatchPdaTaskMapper tasks; + @Resource private DispatchPdaPickMapper picks; + @Resource private DispatchPdaOperationMapper operations; + @Resource private DispatchPalletLockMapper palletLocks; + @Resource private InventoryPositionMapper positions; + @Resource private InventoryCommandService inventory; + @Resource private DispatchPickOrderAttributionService pickAttribution; + @Resource private ObjectMapper objectMapper; + + @Transactional + public DispatchPdaResponses.Task push(String customerId, String dispatchNo, DispatchPdaRequests.Push request) { + DispatchOrder order = requireOrder(customerId, dispatchNo); + requirePdaStatus(order); + requireVehicle(order); + String warehouseId = warehouse(request.warehouseId()); + requireDispatchWarehouse(order, warehouseId); + DispatchPdaOperation replay = replay(customerId, "PUSH", request.idempotencyKey(), request); + if (replay != null) return read(replay, DispatchPdaResponses.Task.class); + items.bindReceivedPallets(customerId, order.getId()); + List dispatchItems = items.findByDispatchOrder(customerId, order.getId()); + if (dispatchItems.isEmpty()) throw conflict("Dispatch has no pallets to push"); + if (dispatchItems.stream().anyMatch(item -> item.getWarehousePalletId() == null)) { + throw conflict("All pre-arranged pallets must be received before PDA push"); + } + DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); + LocalDateTime now = LocalDateTime.now(); + if (task == null) { + task = new DispatchPdaTask(); + task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(order.getId()); + task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); + task.setWarehouseId(warehouseId); + task.setOperatorId(trim(request.operatorId())); task.setOperatorName(trim(request.operatorName())); + task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); tasks.insert(task); + } + if ("PENDING_DEPARTURE".equals(order.getStatus())) { order.setStatus("PICKING"); order.setUpdatedAt(now); orders.updateById(order); } + if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA task warehouse does not match selected warehouse"); + DispatchPdaResponses.Task result = task(customerId, order, task, dispatchItems); + save(customerId, task.getId(), "PUSH", request.idempotencyKey(), request, result); + return result; + } + + @Transactional(readOnly = true) + public DispatchPdaResponses.Task task(String customerId, String dispatchNo) { + DispatchOrder order = requireOrder(customerId, dispatchNo); + DispatchPdaTask task = tasks.findByDispatch(customerId, order.getId()); + if (task == null) throw notFound("Dispatch PDA task not found"); + return task(customerId, order, task, items.findByDispatchOrder(customerId, order.getId())); + } + + @Transactional + public DispatchPdaResponses.Picked pick(String customerId, String dispatchNo, long itemId, + String operatorId, DispatchPdaRequests.Pick request) { + DispatchOrder order = requireOrder(customerId, dispatchNo); + requirePdaStatus(order); + requireVehicle(order); + String warehouseId = warehouse(request.warehouseId()); + requireDispatchWarehouse(order, warehouseId); + DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); + if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) { + throw conflict("Dispatch PDA task is not available for picking"); + } + if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA pick warehouse does not match task warehouse"); + DispatchItem item = items.selectById(itemId); + if (item == null || !customerId.equals(item.getCustomerId()) || !order.getId().equals(item.getDispatchOrderId())) { + throw notFound("Dispatch pallet item not found"); + } + if (!warehouseId.equals(item.getOverseasWarehouseId())) throw conflict("Pallet does not belong to selected warehouse"); + DispatchPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request); + if (replay != null) return read(replay, DispatchPdaResponses.Picked.class); + InventoryPosition position = positions.selectById(request.positionId()); + if (position == null || !customerId.equals(position.getCustomerId()) + || item.getWarehousePalletId() == null || !item.getWarehousePalletId().equals(position.getPalletId())) { + throw conflict("Inventory position does not belong to this dispatch pallet"); + } + long picked = picks.totalItem(customerId, task.getId(), itemId); + if (picked + request.packages() > item.getPackageCount()) throw conflict("Picked packages exceed dispatch pallet packages"); + String keyBase = "DISPATCH:" + order.getDispatchNo() + ":" + itemId + ":" + request.idempotencyKey(); + InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(customerId, request.positionId(), + request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, + keyBase + ":ALLOCATE", request.remark()); + inventory.allocate(customerId, operatorId, move); + inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(customerId, request.positionId(), + request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, + keyBase + ":PICK", request.remark())); + DispatchPdaPick pick = new DispatchPdaPick(); + pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); + pick.setDispatchItemId(itemId); pick.setInventoryPositionId(request.positionId()); pick.setPackages(request.packages()); + pick.setProductQuantity(request.productQuantity()); pick.setIdempotencyKey(request.idempotencyKey()); + pick.setPickedAt(LocalDateTime.now()); pick.setCreatedAt(pick.getPickedAt()); picks.insert(pick); + pickAttribution.attribute(customerId, pick); + long total = picked + request.packages(); + order.setPickedPackageCount((int) (n(order.getPickedPackageCount()) + request.packages())); + task.setStatus("PICKING"); task.setUpdatedAt(LocalDateTime.now()); + if (allPicked(customerId, task.getId(), items.findByDispatchOrder(customerId, order.getId()))) { + task.setStatus("COMPLETED"); task.setCompletedAt(LocalDateTime.now()); order.setStatus("READY_TO_DEPART"); + } + order.setUpdatedAt(LocalDateTime.now()); + orders.updateById(order); + tasks.updateById(task); + DispatchPdaResponses.Picked result = new DispatchPdaResponses.Picked(itemId, request.positionId(), request.packages(), total, false, false); + save(customerId, task.getId(), "PICK", request.idempotencyKey(), request, result); + return result; + } + + @Transactional + public DispatchPdaResponses.PickingCompleted completePicking(String customerId, String dispatchNo, + DispatchPdaRequests.CompletePicking request) { + DispatchOrder found = requireOrder(customerId, dispatchNo); + DispatchOrder order = orders.lockById(customerId, found.getId()); + if (order == null) throw notFound("Dispatch order not found"); + DispatchPdaOperation replay = replay(customerId, "COMPLETE_PICKING", request.idempotencyKey(), request); + if (replay != null) return read(replay, DispatchPdaResponses.PickingCompleted.class).withReplay(); + requireVehicle(order); + DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); + if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) { + throw conflict("Dispatch PDA task is not available for completion"); + } + List source = items.findByDispatchOrder(customerId, order.getId()); + long planned = source.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull) + .mapToLong(Integer::longValue).sum(); + long picked = picks.listTask(customerId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).sum(); + if (picked <= 0 || picked > planned) throw conflict("PDA completion requires a valid picked package count"); + boolean partial = picked < planned; + LocalDateTime now = LocalDateTime.now(); + task.setStatus("COMPLETED"); task.setCompletedAt(now); task.setUpdatedAt(now); tasks.updateById(task); + order.setStatus(partial ? "PARTIAL_READY_TO_DEPART" : "READY_TO_DEPART"); + order.setPartialCompletionReason(partial ? request.reason().trim() : null); + order.setPickedPackageCount((int) picked); + order.setUpdatedAt(now); orders.updateById(order); + DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted(order.getDispatchNo(), + order.getStatus(), planned, picked, planned - picked, partial, false); + save(customerId, task.getId(), "COMPLETE_PICKING", request.idempotencyKey(), request, result); + return result; + } + + /** Final warehouse exit: only fully PDA-picked dispatches can consume picked inventory. */ + @Transactional + public DispatchPdaResponses.Departed depart(String customerId, String dispatchNo, String operatorId, + DispatchPdaRequests.Depart request) { + DispatchOrder found = requireOrder(customerId, dispatchNo); + DispatchOrder order = orders.lockById(customerId, found.getId()); + if (order == null) throw notFound("Dispatch order not found"); + DispatchPdaOperation replay = replay(customerId, "DEPART", request.idempotencyKey(), request); + if (replay != null) return read(replay, DispatchPdaResponses.Departed.class).withReplay(); + requireVehicle(order); + OutboundOrder outbound = outboundOrders.lockByDispatch(customerId, order.getId()); + if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) { + throw conflict("Dispatch must be transferred to a pending outbound order before departure"); + } + DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); + if (task == null || !"COMPLETED".equals(task.getStatus()) + || !("READY_TO_DEPART".equals(order.getStatus()) || "PARTIAL_READY_TO_DEPART".equals(order.getStatus()))) { + throw conflict("Dispatch must complete PDA picking before departure"); + } + + List dispatchItems = items.findByDispatchOrder(customerId, order.getId()); + boolean partial = "PARTIAL_READY_TO_DEPART".equals(order.getStatus()); + if (!partial && !allPicked(customerId, task.getId(), dispatchItems)) { + throw conflict("Every dispatch pallet must be fully picked before departure"); + } + List dispatchPicks = picks.listTask(customerId, task.getId()); + if (dispatchPicks.isEmpty()) throw conflict("Dispatch has no PDA picks to ship"); + long shipped = 0; + for (DispatchPdaPick pick : dispatchPicks) { + String key = "DISPATCH:" + order.getDispatchNo() + ":PICK:" + pick.getId() + ":SHIP"; + inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(customerId, + pick.getInventoryPositionId(), n(pick.getPackages()), + outbound.getOutboundNo(), outbound.getOutboundNo(), null, key, request.remark())); + shipped += n(pick.getPackages()); + } + LocalDateTime now = LocalDateTime.now(); + pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now); + outbound.setStatus("OUTBOUNDED"); + outbound.setUpdatedAt(now); + outboundOrders.updateById(outbound); + order.setShippedPackageCount((int) shipped); + order.setStatus(partial ? "PARTIAL_DEPARTED" : "DEPARTED"); + order.setUpdatedAt(now); + orders.updateById(order); + releasePalletLocks(customerId, order.getId(), now); + DispatchPdaResponses.Departed result = new DispatchPdaResponses.Departed(order.getDispatchNo(), + order.getStatus(), shipped, false); + save(customerId, task.getId(), "DEPART", request.idempotencyKey(), request, result); + return result; + } + + private DispatchPdaResponses.Task task(String customerId, DispatchOrder order, DispatchPdaTask task, List source) { + List values = new ArrayList<>(); + for (DispatchItem item : source) { + List palletPositions = item.getWarehousePalletId() == null ? List.of() + : positions.listPallet(customerId, item.getWarehousePalletId()); + values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(), + item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), item.getPackageCount(), + picks.totalItem(customerId, task.getId(), item.getId()), palletPositions.stream().map(p -> + new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages()), + n(p.getAllocatedPackages()), n(p.getPickedPackages()))).toList())); + } + return new DispatchPdaResponses.Task(task.getId(), task.getTaskNo(), order.getDispatchNo(), order.getVehiclePlate(), task.getWarehouseId(), task.getStatus(), values); + } + + private boolean allPicked(String customerId, Long taskId, List source) { + return source.stream().allMatch(item -> picks.totalItem(customerId, taskId, item.getId()) == item.getPackageCount()); + } + private void releasePalletLocks(String customerId, Long dispatchOrderId, LocalDateTime now) { + List activeLocks = palletLocks.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(DispatchPalletLock::getCustomerId, customerId) + .eq(DispatchPalletLock::getDispatchOrderId, dispatchOrderId) + .eq(DispatchPalletLock::getStatus, "ACTIVE")); + for (DispatchPalletLock lock : activeLocks) { + lock.setStatus("RELEASED"); + lock.setActiveKey(null); + lock.setReleasedAt(now); + lock.setUpdatedAt(now); + palletLocks.updateById(lock); + } + } + private DispatchOrder requireOrder(String customerId, String dispatchNo) { DispatchOrder order = orders.findByDispatchNo(customerId, dispatchNo); if (order == null) throw notFound("Dispatch order not found"); return order; } + private void requirePdaStatus(DispatchOrder order) { + if (!("PENDING_DEPARTURE".equals(order.getStatus()) || "PICKING".equals(order.getStatus()) + || "READY_TO_DEPART".equals(order.getStatus()))) throw conflict("Dispatch has already departed and cannot be changed by PDA"); + } + private void requireVehicle(DispatchOrder order) { if (trim(order.getVehiclePlate()) == null) throw conflict("Vehicle plate is required before PDA push or picking"); } + private static String warehouse(String value) { return required(value, "Warehouse selection is required for PDA work"); } + private static void requireDispatchWarehouse(DispatchOrder order, String warehouseId) { + if (trim(order.getOverseasWarehouseId()) == null || !warehouseId.equals(order.getOverseasWarehouseId().trim())) { + throw conflict("Selected warehouse does not match dispatch warehouse"); + } + } + private DispatchPdaOperation replay(String customerId, String type, String key, Object request) { DispatchPdaOperation op = operations.findKey(customerId, required(key, "Idempotency key is required")); if (op == null) return null; if (!type.equals(op.getOperationType()) || !hash(request).equals(op.getRequestHash())) throw conflict("Idempotency key was reused with a different request"); if (!"COMPLETED".equals(op.getStatus())) throw conflict("PDA operation is still processing"); return op; } + private void save(String customerId, Long taskId, String type, String key, Object request, Object result) { DispatchPdaOperation op = new DispatchPdaOperation(); op.setId(IdWorker.getId()); op.setCustomerId(customerId); op.setTaskId(taskId); op.setOperationType(type); op.setIdempotencyKey(required(key,"Idempotency key is required")); op.setRequestHash(hash(request)); op.setResultJson(json(result)); op.setStatus("COMPLETED"); op.setCreatedAt(LocalDateTime.now()); op.setUpdatedAt(op.getCreatedAt()); operations.insert(op); } + private T read(DispatchPdaOperation op, Class type) { try { return objectMapper.readValue(op.getResultJson(), type); } catch (Exception e) { throw new IllegalStateException("Cannot read PDA replay", e); } } + private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new IllegalStateException(e); } } + private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException(e); } } + private static long n(Long value) { return value == null ? 0 : value; } + private static long n(Integer value) { return value == null ? 0 : value; } + private static String trim(String value) { return value == null || value.trim().isEmpty() ? null : value.trim(); } + private static String required(String value, String message) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); return result; } + 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/pda/service/DispatchPickOrderAttributionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java new file mode 100644 index 000000000..441d30d56 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java @@ -0,0 +1,105 @@ +package com.baiyue.lastleg.dispatch.pda.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPickOrder; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class DispatchPickOrderAttributionService { + @Resource private DispatchPdaPickOrderMapper attributions; + @Resource private LastlegOrderMapper orders; + @Resource private LastlegOrderStatusHistoryMapper history; + + /** Call after the inventory position has been locked/moved and the PDA pick row has been inserted. */ + public List attribute(String customerId, DispatchPdaPick pick) { + long remainingPackages = value(pick.getPackages()); + long totalPackages = remainingPackages; + long totalQuantity = value(pick.getProductQuantity()); + long allocatedPackages = 0; + long allocatedQuantity = 0; + Map pickedByOrder = new LinkedHashMap<>(); + for (InboundStockReceipt receipt : attributions.availableReceipts(customerId, + pick.getInventoryPositionId())) { + if (remainingPackages == 0) break; + long receiptPackages = value(receipt.getReceivedPackages()) + - attributions.attributedPackages(customerId, receipt.getId()); + if (receiptPackages <= 0) continue; + long packages = Math.min(remainingPackages, receiptPackages); + long nextPackages = allocatedPackages + packages; + long nextQuantity = totalPackages == 0 ? 0 : Math.floorDiv(totalQuantity * nextPackages, totalPackages); + long quantity = nextQuantity - allocatedQuantity; + DispatchPdaPickOrder row = new DispatchPdaPickOrder(); + row.setId(IdWorker.getId()); + row.setCustomerId(customerId); + row.setPickId(pick.getId()); + row.setReceiptId(receipt.getId()); + row.setOrderId(receipt.getOrderId()); + row.setPackages(packages); + row.setProductQuantity(quantity); + row.setCreatedAt(pick.getPickedAt() == null ? LocalDateTime.now() : pick.getPickedAt()); + attributions.insert(row); + pickedByOrder.merge(receipt.getOrderId(), packages, Long::sum); + remainingPackages -= packages; + allocatedPackages = nextPackages; + allocatedQuantity = nextQuantity; + } + if (remainingPackages != 0) throw conflict("PDA pick cannot be attributed to inbound receipt orders"); + for (Map.Entry entry : pickedByOrder.entrySet()) { + if (orders.increasePicked(customerId, entry.getKey(), entry.getValue()) != 1) { + throw conflict("Picked packages exceed tail-leg order remaining packages"); + } + } + return pickedByOrder.entrySet().stream().map(e -> new OrderPackages(e.getKey(), e.getValue())).toList(); + } + + /** Call once for each departing PDA pick; repeated dispatch departure is protected by its outer idempotency key. */ + public List depart(String customerId, String outboundNo, String dispatchNo, List picks, + LocalDateTime departedAt) { + Map shippedByOrder = new LinkedHashMap<>(); + for (DispatchPdaPick pick : picks) { + for (DispatchPdaPickOrder row : attributions.listPick(customerId, pick.getId())) { + shippedByOrder.merge(row.getOrderId(), value(row.getPackages()), Long::sum); + } + } + for (Map.Entry entry : shippedByOrder.entrySet()) { + LastlegOrder before = orders.lockById(customerId, entry.getKey()); + if (before == null || orders.transferPickedToShipped(customerId, entry.getKey(), entry.getValue(), outboundNo) != 1) { + throw conflict("Tail-leg order picked packages are inconsistent"); + } + long shipped = value(before.getShippedPackageCount()) + entry.getValue(); + String to = shipped == value(before.getPackageCount()) ? "COMPLETED" : "PARTIALLY_SHIPPED"; + LastlegOrderStatusHistory row = new LastlegOrderStatusHistory(); + row.setOrderId(entry.getKey()); + row.setFromStatus(before.getCurrentStatus()); + row.setToStatus(to); + row.setEventId("DISPATCH_DEPART:" + dispatchNo); + row.setRemark("Dispatch departed " + entry.getValue() + " packages; cumulative shipped " + + shipped + "/" + value(before.getPackageCount())); + row.setCreatedAt(departedAt); + history.insert(row); + } + return shippedByOrder.entrySet().stream().map(e -> new OrderPackages(e.getKey(), e.getValue())).toList(); + } + + public record OrderPackages(Long orderId, long packages) {} + private static long value(Long value) { return value == null ? 0 : value; } + private static long value(Integer value) { return value == null ? 0 : value; } + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java new file mode 100644 index 000000000..bd389d8ea --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -0,0 +1,261 @@ +package com.baiyue.lastleg.dispatch.service; + +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.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; +import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class DispatchService { + public static final String PENDING_DEPARTURE = "PENDING_DEPARTURE"; + public static final String CANCELLED = "CANCELLED"; + + @Resource private DispatchOrderMapper orders; + @Resource private DispatchItemMapper items; + @Resource private DispatchPalletLockMapper locks; + @Resource private DispatchCandidateMapper candidates; + + public List candidates(String customerId, String orderNo, + String fbaWarehouseCode, String sourceStage) { + if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED"); + } + return candidates.available(customerId, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(sourceStage)); + } + + @Transactional + public DispatchResponse preArrange(String customerId, String operatorId, DispatchRequests.PreArrange request) { + List palletIds = request.planPalletIds().stream().filter(Objects::nonNull).distinct().sorted().toList(); + if (palletIds.isEmpty() || palletIds.size() != request.planPalletIds().size()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Plan pallet IDs must be unique and non-empty"); + } + List selected = candidates.lockSelected(customerId, palletIds); + if (selected.size() != palletIds.size()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant"); + } + if (selected.stream().anyMatch(candidate -> candidate.packageCount() == null || candidate.packageCount() <= 0)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Selected pallet has no remaining packages to dispatch"); + } + Set warehouseIds = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId) + .filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).collect(Collectors.toSet()); + if (warehouseIds.size() != 1 || selected.stream().anyMatch(candidate -> candidate.overseasWarehouseId() == null)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "A dispatch must belong to exactly one overseas warehouse"); + } + for (DispatchPalletCandidate candidate : selected) { + boolean planLocked = candidate.planPalletId() != null + && locks.lockActive(customerId, "PLAN:" + candidate.planPalletId()) != null; + boolean receivedLocked = candidate.warehousePalletId() != null + && locks.lockActive(customerId, "RECEIVED:" + candidate.warehousePalletId()) != null; + if (planLocked || receivedLocked) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Pallet is already locked by another active dispatch: " + candidate.palletNo()); + } + } + + LocalDateTime now = LocalDateTime.now(); + DispatchOrder dispatch = new DispatchOrder(); + dispatch.setId(IdWorker.getId()); + dispatch.setCustomerId(customerId); + dispatch.setDispatchNo("PC" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + dispatch.getId()); + dispatch.setOverseasWarehouseId(selected.get(0).overseasWarehouseId()); + dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); + dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected)); + dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); + dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); + dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); + dispatch.setStatus(PENDING_DEPARTURE); + dispatch.setItemCount(selected.size()); + dispatch.setPalletCount(selected.size()); + dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); + dispatch.setPickedPackageCount(0); + dispatch.setShippedPackageCount(0); + dispatch.setCreatedBy(trimToNull(operatorId)); + dispatch.setCreatedAt(now); + dispatch.setUpdatedAt(now); + orders.insert(dispatch); + + for (DispatchPalletCandidate candidate : selected) { + DispatchPalletLock lock = new DispatchPalletLock(); + lock.setId(IdWorker.getId()); + lock.setCustomerId(customerId); + lock.setSourcePalletKey(candidate.sourcePalletKey()); + lock.setActiveKey(candidate.sourcePalletKey()); + lock.setPlanId(candidate.planId()); + lock.setPlanPalletId(candidate.planPalletId()); + lock.setWarehousePalletId(candidate.warehousePalletId()); + lock.setDispatchOrderId(dispatch.getId()); + lock.setStatus("ACTIVE"); + lock.setLockedAt(now); + lock.setCreatedAt(now); + lock.setUpdatedAt(now); + try { + locks.insert(lock); + } catch (DuplicateKeyException exception) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Pallet is already locked by another active dispatch: " + candidate.palletNo(), exception); + } + DispatchItem item = new DispatchItem(); + item.setId(IdWorker.getId()); + item.setDispatchOrderId(dispatch.getId()); + item.setCustomerId(customerId); + item.setPlanId(candidate.planId()); + item.setPlanPalletId(candidate.planPalletId()); + item.setWarehousePalletId(candidate.warehousePalletId()); + item.setOrderId(candidate.orderId()); + item.setOverseasWarehouseId(candidate.overseasWarehouseId()); + item.setOverseasWarehouseName(candidate.overseasWarehouseName()); + item.setOrderNo(candidate.orderNo()); + item.setOrderRefs(candidate.orderRefs()); + item.setInboundCode(candidate.inboundCode()); + item.setPalletNo(candidate.palletNo()); + item.setDestinationWarehouseId(candidate.destinationWarehouseId()); + item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); + item.setDestinationWarehouseName(candidate.destinationWarehouseName()); + item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); + item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); + item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); + item.setSourceStage(candidate.sourceStage()); + item.setCreatedAt(now); + items.insert(item); + } + return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); + } + + public PageResult page(String customerId, DispatchRequests.Query query) { + Page page = orders.selectPage(new Page<>(query.page(), query.size()), + new LambdaQueryWrapper().eq(DispatchOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo()) + .like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate()) + .eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status()) + .like(StringUtils.hasText(query.fbaAppointmentNo()), DispatchOrder::getFbaAppointmentNo, + query.fbaAppointmentNo()) + .orderByDesc(DispatchOrder::getCreatedAt)); + List records = page.getRecords().stream() + .map(order -> response(order, items.findByDispatchOrder(customerId, order.getId()))).toList(); + return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); + } + + public DispatchResponse detail(String customerId, String dispatchNo) { + DispatchOrder dispatch = find(customerId, dispatchNo); + return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); + } + + @Transactional + public DispatchResponse update(String customerId, String dispatchNo, DispatchRequests.Update request) { + DispatchOrder dispatch = orders.lockById(customerId, find(customerId, dispatchNo).getId()); + if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + // Vehicle plate is intentionally editable through all dispatch states. + dispatch.setVehiclePlate(trimToNull(request.vehiclePlate())); + dispatch.setDriverName(trimToNull(request.driverName())); + dispatch.setDriverPhone(trimToNull(request.driverPhone())); + dispatch.setCarrierName(trimToNull(request.carrierName())); + dispatch.setRoute(trimToNull(request.route())); + dispatch.setDepartAt(request.departAt()); + dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); + dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); + dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); + dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt()); + if (StringUtils.hasText(request.status())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Dispatch status is PDA-managed and cannot be changed by the edit endpoint"); + } + dispatch.setUpdatedAt(LocalDateTime.now()); + orders.updateById(dispatch); + return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); + } + + @Transactional + public void cancel(String customerId, String dispatchNo) { + DispatchOrder dispatch = orders.lockById(customerId, find(customerId, dispatchNo).getId()); + if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + if (!(PENDING_DEPARTURE.equals(dispatch.getStatus()))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Dispatched order cannot be cancelled"); + } + LocalDateTime now = LocalDateTime.now(); + dispatch.setStatus(CANCELLED); + dispatch.setUpdatedAt(now); + orders.updateById(dispatch); + List activeLocks = locks.selectList(new LambdaQueryWrapper() + .eq(DispatchPalletLock::getCustomerId, customerId) + .eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId()) + .eq(DispatchPalletLock::getStatus, "ACTIVE")); + for (DispatchPalletLock lock : activeLocks) { + lock.setStatus("RELEASED"); + lock.setActiveKey(null); + lock.setReleasedAt(now); + lock.setUpdatedAt(now); + locks.updateById(lock); + } + } + + private DispatchOrder find(String customerId, String dispatchNo) { + DispatchOrder dispatch = orders.findByDispatchNo(customerId, dispatchNo); + if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + return dispatch; + } + + private DispatchResponse response(DispatchOrder order, List itemList) { + String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream()) + .distinct().collect(Collectors.joining(",")); + return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(), + order.getDriverPhone(), order.getCarrierName(), orderNos, order.getRoute(), + order.getOverseasWarehouseId(), order.getOverseasWarehouseName(), order.getDepartAt(), + order.getFbaAppointmentNo(), order.getFbaAppointmentStatus(), order.getVehicleAppointmentStatus(), + order.getEstimatedArrivalAt(), order.getStatus(), Objects.requireNonNullElse(order.getItemCount(), 0), + Objects.requireNonNullElse(order.getPalletCount(), 0), Objects.requireNonNullElse(order.getPackageCount(), 0), + Objects.requireNonNullElse(order.getPickedPackageCount(), 0), + Objects.requireNonNullElse(order.getShippedPackageCount(), 0), + Math.max(0, Objects.requireNonNullElse(order.getPackageCount(), 0) + - Objects.requireNonNullElse(order.getShippedPackageCount(), 0)), order.getPartialCompletionReason(), + order.getCreatedAt(), itemList.stream().map(item -> new DispatchResponse.Item(item.getId(), item.getPlanPalletId(), + item.getWarehousePalletId(), item.getPalletNo(), item.getOverseasWarehouseId(), item.getOverseasWarehouseName(), + item.getOrderNo(), item.getOrderRefs(), item.getInboundCode(), + item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), + Objects.requireNonNullElse(item.getPackageCount(), 0), item.getVolumeCbm(), item.getSourceStage())).toList()); + } + + private static String route(List selected) { + return selected.stream().map(DispatchPalletCandidate::destinationWarehouseCode).filter(StringUtils::hasText) + .distinct().collect(Collectors.joining(" -> ")); + } + + private static List splitOrderRefs(String refs, String fallback) { + String value = StringUtils.hasText(refs) ? refs : fallback; + return StringUtils.hasText(value) ? List.of(value.split(",")) : List.of(); + } + + private static String defaultStatus(String status) { + return StringUtils.hasText(status) ? status.trim().toUpperCase() : "PENDING"; + } + + private static String trimToNull(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/DropshipMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/DropshipMapperConfiguration.java new file mode 100644 index 000000000..d1c3c3667 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/DropshipMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.dropship; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.dropship.mapper") +public class DropshipMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/controller/DropshipOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/controller/DropshipOrderController.java new file mode 100644 index 000000000..c52914792 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/controller/DropshipOrderController.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dropship.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.dropship.dto.DropshipRequests; +import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/internal/v1/lastleg/dropship") +public class DropshipOrderController { + @Resource private DropshipOrderService service; + @Resource private InternalServiceTokenGuard tokenGuard; + + @PostMapping("/receive") + public ApiResponse receive(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody DropshipRequests.Receive request) { + tokenGuard.verify(token); + return ApiResponse.success(service.receive(request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java new file mode 100644 index 000000000..1f16c8c93 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.dropship.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.util.List; + +public final class DropshipRequests { + private DropshipRequests() {} + public record Receive(@NotBlank String customerId, @NotNull Long orderId, @NotNull Long warehouseId, + @NotBlank String idempotencyKey, @NotEmpty List<@Valid Pallet> pallets) {} + public record Pallet(@NotBlank String palletNo, @NotEmpty List<@Valid SkuPackages> skuPackages) {} + public record SkuPackages(@NotBlank String skuCode, String productName, @Positive long packageCount, + @Positive long productQuantity, + @NotEmpty List<@NotBlank String> cartonMarks) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCarton.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCarton.java new file mode 100644 index 000000000..782f59b26 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCarton.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dropship.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data @TableName("ll_dropship_carton") +public class DropshipCarton { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long orderId; + private Long profileId; + private Long warehousePalletId; + private String palletNo; + private String skuCode; + private String cartonMark; + private Integer cartonSequence; + private String status; + private String outboundOrderNo; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCartonLedger.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCartonLedger.java new file mode 100644 index 000000000..d96b4080a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCartonLedger.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.dropship.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data @TableName("ll_dropship_carton_ledger") +public class DropshipCartonLedger { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long orderId; + private Long cartonId; + private String cartonMark; + private String skuCode; + private String operationType; + private Long beforePackages; + private Long deltaPackages; + private Long afterPackages; + private String businessOrderNo; + private String idempotencyKey; + private String remark; + private LocalDateTime occurredAt; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipOrderProfile.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipOrderProfile.java new file mode 100644 index 000000000..15bc5046a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipOrderProfile.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.dropship.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data @TableName("ll_dropship_order_profile") +public class DropshipOrderProfile { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long orderId; + private Long warehousePalletId; + private String palletNo; + /** Immutable pallet number snapshot supplied by the first-leg handover event. */ + private String sourcePalletNo; + private Long inventoryPositionId; + private String status; + private String idempotencyKey; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipSkuInventory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipSkuInventory.java new file mode 100644 index 000000000..ee7ba183a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipSkuInventory.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.dropship.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data @TableName("ll_dropship_sku_inventory") +public class DropshipSkuInventory { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long orderId; + private Long profileId; + private String skuCode; + private String productName; + private Long packageCount; + private Long productQuantity; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonLedgerMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonLedgerMapper.java new file mode 100644 index 000000000..8db409367 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonLedgerMapper.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.dropship.mapper; +import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.*; +public interface DropshipCartonLedgerMapper extends BaseMapper { + @Select("SELECT * FROM ll_dropship_carton_ledger WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + DropshipCartonLedger findIdempotency(@Param("customerId") String customerId, @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java new file mode 100644 index 000000000..6c2ad8ed2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.dropship.mapper; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.*; +import java.util.List; +public interface DropshipCartonMapper extends BaseMapper { + @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} AND carton_mark=#{cartonMark} LIMIT 1") + DropshipCarton find(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("cartonMark") String cartonMark); + + @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} ORDER BY carton_sequence") + List listProfile(@Param("customerId") String customerId, @Param("profileId") Long profileId); + + @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,carton_sequence") + List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java new file mode 100644 index 000000000..01a7d1da2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.dropship.mapper; +import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.*; +import java.util.List; +public interface DropshipOrderProfileMapper extends BaseMapper { + @Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY source_pallet_no") + List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java new file mode 100644 index 000000000..b0f1c7a5a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.dropship.mapper; + +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +public interface DropshipSkuInventoryMapper extends BaseMapper { + @Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,sku_code") + List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java new file mode 100644 index 000000000..6d37fc43f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java @@ -0,0 +1,184 @@ +package com.baiyue.lastleg.dropship.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.dropship.dto.DropshipRequests; +import com.baiyue.lastleg.dropship.entity.*; +import com.baiyue.lastleg.dropship.mapper.*; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class DropshipOrderService { + @Resource private LastlegOrderMapper orders; + @Resource private DropshipOrderProfileMapper profiles; + @Resource private DropshipSkuInventoryMapper skuInventory; + @Resource private DropshipCartonMapper cartons; + @Resource private DropshipCartonLedgerMapper ledger; + + @Transactional + public int receive(DropshipRequests.Receive request) { + LastlegOrder order = orders.selectById(request.orderId()); + if (order == null || !request.customerId().equals(order.getCustomerId())) throw conflict("Tail order not found"); + if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) throw conflict("Order is not a one-click dropship order"); + long totalPackages = request.pallets().stream().flatMap(value -> value.skuPackages().stream()) + .mapToLong(DropshipRequests.SkuPackages::packageCount).sum(); + long totalQuantity = request.pallets().stream().flatMap(value -> value.skuPackages().stream()) + .mapToLong(DropshipRequests.SkuPackages::productQuantity).sum(); + if (order.getPackageCount() != null && order.getPackageCount().longValue() != totalPackages) { + throw conflict("SKU package total does not equal order package count"); + } + if (order.getProductQuantity() != null && order.getProductQuantity().longValue() != totalQuantity) { + throw conflict("SKU product quantity total does not equal order product quantity"); + } + List existing = profiles.listOrder(request.customerId(), request.orderId()); + if (!existing.isEmpty()) { + Set existingPallets = existing.stream().map(DropshipOrderProfile::getSourcePalletNo) + .collect(java.util.stream.Collectors.toSet()); + Set requestedPallets = request.pallets().stream().map(value -> value.palletNo().trim()) + .collect(java.util.stream.Collectors.toSet()); + if (!existingPallets.equals(requestedPallets)) throw conflict("Dropship order pallet snapshot conflicts with existing data"); + return Math.toIntExact(totalPackages); + } + + validateMarks(request); + LocalDateTime now = LocalDateTime.now(); + for (DropshipRequests.Pallet inputPallet : request.pallets()) { + DropshipOrderProfile profile = profile(request, order, inputPallet, now); + profiles.insert(profile); + int sequence = 0; + for (DropshipRequests.SkuPackages input : inputPallet.skuPackages()) { + DropshipSkuInventory inventory = new DropshipSkuInventory(); + inventory.setId(IdWorker.getId()); inventory.setCustomerId(request.customerId()); inventory.setOrderId(order.getId()); + inventory.setProfileId(profile.getId()); inventory.setSkuCode(input.skuCode()); inventory.setProductName(input.productName()); + inventory.setPackageCount(input.packageCount()); inventory.setProductQuantity(input.productQuantity()); + inventory.setCreatedAt(now); inventory.setUpdatedAt(now); + skuInventory.insert(inventory); + for (String mark : input.cartonMarks()) { + DropshipCarton carton = new DropshipCarton(); + carton.setId(IdWorker.getId()); carton.setCustomerId(request.customerId()); carton.setOrderId(order.getId()); + carton.setProfileId(profile.getId()); carton.setWarehousePalletId(null); carton.setPalletNo(null); + carton.setSkuCode(input.skuCode()); carton.setCartonMark(mark.trim()); carton.setCartonSequence(++sequence); + carton.setStatus("PENDING_INBOUND"); carton.setVersion(0L); carton.setCreatedAt(now); carton.setUpdatedAt(now); + cartons.insert(carton); + ledger.insert(stockInFact(request, order, carton, now)); + } + } + } + return Math.toIntExact(totalPackages); + } + + private void validateMarks(DropshipRequests.Receive request) { + Set marks = new HashSet<>(); + Set palletNos = new HashSet<>(); + for (DropshipRequests.Pallet pallet : request.pallets()) { + if (!palletNos.add(pallet.palletNo().trim())) throw conflict("Duplicate pallet number: " + pallet.palletNo()); + Set skus = new HashSet<>(); + for (DropshipRequests.SkuPackages item : pallet.skuPackages()) { + if (item.packageCount() != item.cartonMarks().size()) { + throw conflict("SKU packageCount must equal cartonMarks count: " + item.skuCode()); + } + if (!skus.add(item.skuCode().trim())) throw conflict("Duplicate SKU on pallet: " + item.skuCode()); + for (String mark : item.cartonMarks()) { + if (!marks.add(mark.trim())) throw conflict("Duplicate carton mark: " + mark); + } + } + } + } + + private DropshipOrderProfile profile(DropshipRequests.Receive request, LastlegOrder order, + DropshipRequests.Pallet input, LocalDateTime now) { + DropshipOrderProfile profile = new DropshipOrderProfile(); + profile.setId(IdWorker.getId()); profile.setCustomerId(request.customerId()); profile.setOrderId(order.getId()); + profile.setSourcePalletNo(input.palletNo().trim()); + profile.setStatus("PENDING_INBOUND"); profile.setVersion(0L); + profile.setIdempotencyKey(request.idempotencyKey() + ":" + input.palletNo().trim()); + profile.setCreatedAt(now); profile.setUpdatedAt(now); + return profile; + } + + @Transactional + public void activateAfterStockIn(String customerId, Long orderId, Long taskId, + List receipts) { + List profilesForOrder = profiles.listOrder(customerId, orderId); + if (profilesForOrder.isEmpty()) return; + if (profilesForOrder.stream().allMatch(profile -> "ACTIVE".equals(profile.getStatus()))) return; + if (profilesForOrder.stream().anyMatch(profile -> "ACTIVE".equals(profile.getStatus()))) { + throw conflict("Dropship pallet activation is incomplete; repair profiles before retrying"); + } + Map> receiptsByPallet = receipts.stream() + .filter(receipt -> receipt.getWarehousePalletId() != null) + .collect(Collectors.groupingBy(InboundStockReceipt::getWarehousePalletId)); + if (receiptsByPallet.size() != profilesForOrder.size()) { + throw conflict("PDA pallet count does not match dropship source pallet count"); + } + List orderedProfiles = profilesForOrder.stream() + .sorted(java.util.Comparator.comparing(DropshipOrderProfile::getSourcePalletNo)).toList(); + List> orderedReceipts = receiptsByPallet.values().stream() + .sorted(java.util.Comparator.comparing(value -> value.get(0).getWarehousePalletId())).toList(); + LocalDateTime now = LocalDateTime.now(); + for (int index = 0; index < orderedProfiles.size(); index++) { + DropshipOrderProfile profile = orderedProfiles.get(index); + List actual = orderedReceipts.get(index); + List profileCartons = cartons.listProfile(customerId, profile.getId()); + long expected = profileCartons.size(); + long received = actual.stream().mapToLong(value -> value.getReceivedPackages() == null ? 0 : value.getReceivedPackages()).sum(); + if (expected != received) throw conflict("PDA package count does not match dropship carton snapshot"); + Long positionId = actual.get(0).getInventoryPositionId(); + if (actual.stream().anyMatch(value -> !java.util.Objects.equals(positionId, value.getInventoryPositionId()))) { + throw conflict("Dropship source pallet maps to multiple inventory positions"); + } + InboundStockReceipt first = actual.get(0); + profile.setWarehousePalletId(first.getWarehousePalletId()); profile.setPalletNo(first.getPalletNo()); + profile.setInventoryPositionId(positionId); profile.setStatus("ACTIVE"); profile.setVersion(value(profile.getVersion()) + 1); + profile.setUpdatedAt(now); profiles.updateById(profile); + for (DropshipCarton carton : profileCartons) { + carton.setWarehousePalletId(first.getWarehousePalletId()); carton.setPalletNo(first.getPalletNo()); + carton.setStatus("AVAILABLE"); carton.setVersion(value(carton.getVersion()) + 1); carton.setUpdatedAt(now); + cartons.updateById(carton); + DropshipCartonLedger activation = stockInActivation(customerId, orderId, taskId, carton, now); + ledger.insert(activation); + } + } + } + + private DropshipCartonLedger stockInActivation(String customerId, Long orderId, Long taskId, + DropshipCarton carton, LocalDateTime now) { + DropshipCartonLedger fact = new DropshipCartonLedger(); + fact.setId(IdWorker.getId()); fact.setCustomerId(customerId); fact.setOrderId(orderId); + fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(carton.getSkuCode()); + fact.setOperationType("STOCK_IN"); fact.setBeforePackages(0L); fact.setDeltaPackages(1L); fact.setAfterPackages(1L); + fact.setIdempotencyKey("PDA_STOCK_IN:" + taskId + ":" + carton.getId()); fact.setOccurredAt(now); fact.setCreatedAt(now); + return fact; + } + + private DropshipCartonLedger stockInFact(DropshipRequests.Receive request, LastlegOrder order, + DropshipCarton carton, LocalDateTime now) { + DropshipCartonLedger fact = new DropshipCartonLedger(); + fact.setId(IdWorker.getId()); fact.setCustomerId(request.customerId()); fact.setOrderId(order.getId()); + fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(carton.getSkuCode()); + fact.setOperationType("SNAPSHOT_CREATED"); fact.setBeforePackages(0L); fact.setDeltaPackages(0L); fact.setAfterPackages(0L); + fact.setBusinessOrderNo(order.getOrderNo()); + fact.setIdempotencyKey(request.idempotencyKey() + ":" + carton.getCartonMark()); + fact.setOccurredAt(now); fact.setCreatedAt(now); + return fact; + } + + private static long value(Long value) { return value == null ? 0L : value; } + + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } +} 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 new file mode 100644 index 000000000..c73e5fb3b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java @@ -0,0 +1,27 @@ +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.util.List; + +public record LastlegOrderDetailResponse( + LastlegOrder order, + List cargo, + List statusHistory, + List dropshipPallets +) { + public record DropshipPallet( + Long profileId, + String sourcePalletNo, + Long warehousePalletId, + String warehousePalletNo, + Long inventoryPositionId, + String status, + List skuInventory, + List cartons + ) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java new file mode 100644 index 000000000..712d2b829 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java @@ -0,0 +1,55 @@ +package com.baiyue.lastleg.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class LastlegOrderRequests { + private LastlegOrderRequests() { + } + + public record Query( + @Min(1) long page, + @Min(1) @Max(200) long size, + String orderNo, + String inboundCode, + String sourceOrderNo, + String containerNo, + String status, + String transferWarehouseCode, + Boolean abnormal, + Boolean detained, + LocalDateTime createdFrom, + LocalDateTime createdTo + ) { + public Query { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } + + public record TransferDestination( + @NotEmpty @Size(max = 200) List orderIds, + @NotBlank @Size(max = 128) String warehouseId, + @NotBlank @Size(max = 128) String warehouseCode, + @NotBlank @Size(max = 255) String warehouseName, + @Size(max = 500) String remark + ) { + } + + public record SingleTransferDestination( + @NotBlank @Size(max = 128) String warehouseId, + @NotBlank @Size(max = 128) String warehouseCode, + @NotBlank @Size(max = 255) String warehouseName, + @Size(max = 500) String remark + ) { + public TransferDestination withOrderId(long orderId) { + return new TransferDestination(List.of(orderId), warehouseId, warehouseCode, warehouseName, remark); + } + } +} 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 new file mode 100644 index 000000000..0ae6357f7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Schema(description = "Tail-leg order row at inbound-code granularity") +public record LastlegOrderSummary( + Long id, + String orderNo, + String orderType, + String sourceInboundId, + String inboundCode, + String sourceOrderNo, + String customerName, + String overseasWarehouseId, + String overseasWarehouseName, + String transferWarehouseId, + String transferWarehouseCode, + String transferWarehouseName, + String fbaWarehouseCode, + String containerNo, + String containerStatus, + Boolean abnormal, + Boolean detained, + Integer palletCount, + Integer packageCount, + Integer pickedPackageCount, + Integer shippedPackageCount, + Integer remainingPackageCount, + BigDecimal volumeCbm, + Integer productQuantity, + BigDecimal volumeWeightKg, + LocalDateTime estimatedArrivalAt, + LocalDateTime actualArrivalAt, + LocalDateTime estimatedWarehouseAt, + String inboundOrderNo, + String outboundOrderNo, + String deliveryOrderNo, + String relabelOrderNo, + String relabelStatus, + String palletMergeFlag, + String currentStatus, + String subOrderStatus, + String privateRemark, + String customerRemark, + String warehouseRemark, + LocalDateTime createdAt +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/package-info.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/package-info.java new file mode 100644 index 000000000..5f6f3b270 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/package-info.java @@ -0,0 +1,5 @@ +/** + * API request and response models for last-leg delivery. + * / 尾程配送 API 请求与响应模型。 + */ +package com.baiyue.lastleg.dto; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventInbox.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventInbox.java new file mode 100644 index 000000000..fac02ba0c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventInbox.java @@ -0,0 +1,3 @@ +package com.baiyue.lastleg.entity; +import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime; +@Data @TableName("ll_loading_event_inbox") public class LastlegEventInbox { @TableId private String eventId; private String customerId; private String eventType; private Integer eventVersion; private String payloadHash; private String payloadJson; private String status; private LocalDateTime receivedAt; private LocalDateTime processedAt; private String lastError; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java new file mode 100644 index 000000000..4337ed4e0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_order") +public class LastlegOrder { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String orderNo; + private String customerId; + private String orderType; + private String inboundCode; + private String sourceRegion; + private String sourceInboundId; + private String rootInboundId; + private String sourceOrderId; + private String sourceOrderNo; + private String rootOrderId; + private String customerName; + private String overseasWarehouseId; + private String overseasWarehouseName; + private String fbaWarehouseCode; + private String containerNo; + private String transferWarehouseId; + private String transferWarehouseCode; + private String transferWarehouseName; + private String containerStatus; + private Boolean abnormal; + private Boolean detained; + private Integer palletCount; + private Integer packageCount; + private Integer pickedPackageCount; + private Integer shippedPackageCount; + private BigDecimal volumeCbm; + private Integer productQuantity; + private BigDecimal volumeWeightKg; + private LocalDateTime estimatedArrivalAt; + private LocalDateTime actualArrivalAt; + private LocalDateTime estimatedWarehouseAt; + private String inboundOrderNo; + private String outboundOrderNo; + private String deliveryOrderNo; + private String relabelOrderNo; + private String relabelStatus; + private String palletMergeFlag; + private String currentStatus; + private String subOrderStatus; + private String privateRemark; + private String customerRemark; + private String warehouseRemark; + private String sourceEventId; + private String sourceLoadingPlanId; + private Long sourceLoadingPlanVersion; + private Long sourceHandoverVersion; + private String sourcePayloadHash; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderDetail.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderDetail.java new file mode 100644 index 000000000..557f7d7df --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderDetail.java @@ -0,0 +1,3 @@ +package com.baiyue.lastleg.entity; +import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.math.BigDecimal; +@Data @TableName("ll_order_detail") public class LastlegOrderDetail { @TableId(type=IdType.AUTO) private Long id; private Long orderId; private String sourceInboundDetailId; private String skuCode; private String productNameZh; private String productNameEn; private Integer packageCount; private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm; private String destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; private String fbaTrackingNo; private String shipmentNo; private String shippingMark; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderStatusHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderStatusHistory.java new file mode 100644 index 000000000..4ca0f92a2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderStatusHistory.java @@ -0,0 +1,3 @@ +package com.baiyue.lastleg.entity; +import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime; +@Data @TableName("ll_loading_order_status_history") public class LastlegOrderStatusHistory { @TableId(type=IdType.AUTO) private Long id; private Long orderId; private String fromStatus; private String toStatus; private String eventId; private String remark; private LocalDateTime createdAt; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/package-info.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/package-info.java new file mode 100644 index 000000000..95c097114 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/package-info.java @@ -0,0 +1,5 @@ +/** + * Persistence entities mapped to last-leg MySQL tables. + * / 映射尾程 MySQL 数据表的持久化实体。 + */ +package com.baiyue.lastleg.entity; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/InboundPlanMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/InboundPlanMapperConfiguration.java new file mode 100644 index 000000000..a165bf4b2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/InboundPlanMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan({"com.baiyue.lastleg.inboundplan.mapper", "com.baiyue.lastleg.inboundplan.pda.mapper"}) +public class InboundPlanMapperConfiguration { +} 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 new file mode 100644 index 000000000..a360ddde1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -0,0 +1,74 @@ +package com.baiyue.lastleg.inboundplan.controller; + +import com.baiyue.common.core.api.ApiResponse; +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.service.InboundFbaAppointmentService; +import com.baiyue.lastleg.inboundplan.service.InboundPlanService; +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.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/inbound-plans") +@Tag(name = "Tail-leg inbound plans") +public class InboundPlanController { + @Resource private InboundPlanService service; + @Resource private InboundFbaAppointmentService appointmentService; + + @PostMapping("/convert") + @Operation(summary = "Convert the entire container to one inbound plan") + 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") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @RequestParam(required = false) String customerId) { + return ApiResponse.success(service.detailByPlanNo(scope(user, customerId), planNo)); + } + + @PostMapping("/fba-appointments/batch") + @Operation(summary = "Create one FBA appointment for selected inbound lines") + public ApiResponse batchFbaAppointment( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody BatchFbaAppointmentRequest request) { + String customerId = scope(user, request.customerId()); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(appointmentService.batchCreate(customerId, operatorId, request)); + } + + @GetMapping("/fba-appointments/{appointmentNo}") + @Operation(summary = "Get an FBA appointment and its selected inbound lines") + public ApiResponse fbaAppointmentDetail( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String appointmentNo, + @RequestParam(required = false) String customerId) { + return ApiResponse.success(appointmentService.detail(scope(user, customerId), appointmentNo)); + } + + private String scope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Platform request must specify target tenant"); + } + return requestedCustomer; + } + return String.valueOf(user.tenantId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/BatchFbaAppointmentRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/BatchFbaAppointmentRequest.java new file mode 100644 index 000000000..1572cb552 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/BatchFbaAppointmentRequest.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public record BatchFbaAppointmentRequest( + String customerId, + @NotEmpty @Size(max = 500) List<@NotNull Long> lineIds, + @NotBlank @Size(max = 64) String appointmentNo, + @NotNull LocalDateTime appointmentAt, + @NotBlank @Size(max = 32) String appointmentStatus, + @Size(max = 1000) String appointmentRemark +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java new file mode 100644 index 000000000..8d4a6a20b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.NotNull; + +public record ConvertInboundPlanRequest( + @NotNull Long orderId, + String customerId +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java new file mode 100644 index 000000000..acf32635b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record FbaAppointmentResponse( + Long id, + String customerId, + String appointmentNo, + LocalDateTime appointmentAt, + String appointmentStatus, + String appointmentRemark, + String fbaWarehouseCode, + int selectedCount, + String createdBy, + LocalDateTime createdAt, + LocalDateTime updatedAt, + List items +) { + public record Item(Long id, Long planId, Long lineId, Long orderId, String inboundCode, + String fbaWarehouseCode) { + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java new file mode 100644 index 000000000..f50f69579 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public record InboundPlanResponse( + Long id, + String planNo, + String customerId, + String containerNo, + String status, + int lineCount, + int palletCount, + BigDecimal totalVolumeCbm, + List lines, + List pallets, + List allocations +) { + public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + BigDecimal volumeCbm, int requiredPackageCount, int plannedPalletCount) { + } + + public record Pallet(Long id, String palletNo, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + BigDecimal capacityCbm, BigDecimal allocatedVolumeCbm, int sequenceNo, + Long warehousePalletId, Long warehouseLocationId, Long warehouseResourceLockId, + String assignmentStatus, LocalDateTime lockedAt, BigDecimal actualVolumeCbm, + int actualPackageCount) { + } + + public record Allocation(Long lineId, Long palletId, BigDecimal allocatedVolumeCbm, + int plannedPackageCount, int countedPackageCount, int pickedPackageCount, + BigDecimal actualVolumeCbm, String status, int version) { + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java new file mode 100644 index 000000000..20f34801b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inboundplan.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_inbound_fba_appointment") +public class InboundFbaAppointment { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private String appointmentNo; + private LocalDateTime appointmentAt; + private String appointmentStatus; + private String appointmentRemark; + private String fbaWarehouseCode; + private Integer selectedCount; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointmentItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointmentItem.java new file mode 100644 index 000000000..96a262753 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointmentItem.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.inboundplan.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_inbound_fba_appointment_item") +public class InboundFbaAppointmentItem { + @TableId(type = IdType.INPUT) + private Long id; + private Long appointmentId; + private Long planId; + private Long lineId; + private Long orderId; + private String inboundCode; + private String fbaWarehouseCode; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java new file mode 100644 index 000000000..a796f8679 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inboundplan.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_inbound_plan") +public class InboundPlan { + @TableId(type = IdType.INPUT) + private Long id; + private String planNo; + private String customerId; + private String containerNo; + private String status; + private Integer lineCount; + private Integer palletCount; + private BigDecimal totalVolumeCbm; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java new file mode 100644 index 000000000..070d1805f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inboundplan.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +@TableName("ll_inbound_plan_line") +public class InboundPlanLine { + @TableId(type = IdType.INPUT) + private Long id; + private Long planId; + private Long orderId; + private String inboundCode; + private String fbaWarehouseCode; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private BigDecimal volumeCbm; + private Integer requiredPackageCount; + private Integer plannedPalletCount; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java new file mode 100644 index 000000000..b998ed6fc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.inboundplan.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_inbound_plan_pallet") +public class InboundPlanPallet { + @TableId(type = IdType.INPUT) + private Long id; + private Long planId; + private String palletNo; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private BigDecimal capacityCbm; + private BigDecimal allocatedVolumeCbm; + private Integer sequenceNo; + private Long warehousePalletId; + private Long warehouseLocationId; + private Long warehouseResourceLockId; + private String assignmentStatus; + private LocalDateTime lockedAt; + private BigDecimal actualVolumeCbm; + private Integer actualPackageCount; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPalletAllocation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPalletAllocation.java new file mode 100644 index 000000000..e716cbe98 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPalletAllocation.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inboundplan.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +@TableName("ll_inbound_plan_pallet_allocation") +public class InboundPlanPalletAllocation { + @TableId(type = IdType.INPUT) + private Long id; + private Long planId; + private Long lineId; + private Long palletId; + private BigDecimal allocatedVolumeCbm; + private Integer plannedPackageCount; + private Integer countedPackageCount; + private Integer pickedPackageCount; + private BigDecimal actualVolumeCbm; + private String status; + private Integer version; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentItemMapper.java new file mode 100644 index 000000000..d80900dc6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentItemMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundFbaAppointmentItemMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentMapper.java new file mode 100644 index 000000000..c94458295 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundFbaAppointmentMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java new file mode 100644 index 000000000..e2972ecba --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot; +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 InboundFbaAppointmentQueryMapper { + @Select("") + List lockLines(@Param("customerId") String customerId, + @Param("lineIds") List lineIds); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanLineMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanLineMapper.java new file mode 100644 index 000000000..d7480e0d6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanLineMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundPlanLineMapper extends BaseMapper { +} 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 new file mode 100644 index 000000000..893df0507 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +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; + +@Mapper +public interface InboundPlanMapper extends BaseMapper { + @Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND container_no=#{containerNo} LIMIT 1") + InboundPlan findByContainer(@Param("customerId") String customerId, @Param("containerNo") String containerNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java new file mode 100644 index 000000000..ed66ee48d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +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 InboundPlanOrderMapper { + @Select("SELECT container_no FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1") + String findContainerNo(@Param("customerId") String customerId, @Param("orderId") Long orderId); + + @Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.container_no AS containerNo," + + "o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," + + "COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," + + "NULLIF(o.fba_warehouse_code,'') AS fbaWarehouseCode," + + "COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," + + "COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," + + "COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," + + "COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) AS destinationCount," + + "SUM(CASE WHEN d.id IS NOT NULL " + + "AND COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL " + + "AND COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#') " + + "<> COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) THEN 1 ELSE 0 END) " + + "AS destinationMismatchCount " + + "FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " + + "WHERE o.customer_id=#{customerId} AND o.container_no=#{containerNo} " + + "GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.fba_warehouse_code," + + "o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " + + "ORDER BY o.id FOR UPDATE") + List lockContainerOrders(@Param("customerId") String customerId, + @Param("containerNo") String containerNo); + + @Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," + + "pallet_merge_flag='MERGED',pallet_count=#{palletCount},updated_at=#{updatedAt} " + + "WHERE customer_id=#{customerId} AND id=#{orderId}") + int markPlanned(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("planNo") String planNo, @Param("palletCount") int palletCount, + @Param("updatedAt") LocalDateTime updatedAt); + + @Insert("INSERT INTO ll_loading_order_status_history(order_id,from_status,to_status,remark,created_at) " + + "VALUES(#{orderId},#{fromStatus},'INBOUND_PLANNED',#{remark},#{createdAt})") + int insertHistory(@Param("orderId") Long orderId, @Param("fromStatus") String fromStatus, + @Param("remark") String remark, @Param("createdAt") LocalDateTime createdAt); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletAllocationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletAllocationMapper.java new file mode 100644 index 000000000..a2c825b35 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletAllocationMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundPlanPalletAllocationMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletMapper.java new file mode 100644 index 000000000..c700504e8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundPlanPalletMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentLineSnapshot.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentLineSnapshot.java new file mode 100644 index 000000000..2244c4d85 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentLineSnapshot.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.inboundplan.model; + +public record FbaAppointmentLineSnapshot( + Long lineId, + Long planId, + Long orderId, + String inboundCode, + String fbaWarehouseCode +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentStatus.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentStatus.java new file mode 100644 index 000000000..99644373d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentStatus.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.inboundplan.model; + +public enum FbaAppointmentStatus { + PENDING, + SUCCESS, + FAILED, + CANCELLED +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java new file mode 100644 index 000000000..b4d7d5886 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.inboundplan.model; + +import java.math.BigDecimal; + +public record SourceOrderSnapshot( + Long orderId, + String customerId, + String containerNo, + String inboundCode, + String currentStatus, + String inboundOrderNo, + BigDecimal volumeCbm, + int packageCount, + String fbaWarehouseCode, + String destinationWarehouseId, + String destinationWarehouseCode, + String destinationWarehouseName, + int destinationCount, + int destinationMismatchCount +) { + public SourceOrderSnapshot(Long orderId, String customerId, String containerNo, String inboundCode, + String currentStatus, String inboundOrderNo, BigDecimal volumeCbm, + int packageCount, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + int destinationCount, int destinationMismatchCount) { + this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm, + packageCount, null, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, destinationCount, destinationMismatchCount); + } +} 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 new file mode 100644 index 000000000..f024f8efd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.inboundplan.pda.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; +import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService; +import jakarta.validation.Valid; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/inbound-plans/{planNo}/pallets") +public class InboundPalletAssignmentController { + @Resource private InboundPdaWorkflowService service; + + @PostMapping("/{planPalletId}/assign") + public ApiResponse assign(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, @PathVariable long planPalletId, + @RequestParam(required = false) String customerId, + @Valid @RequestBody PdaRequests.Assign request) { + service.assign(scope(user, customerId), planNo, planPalletId, request); + return ApiResponse.success(null); + } + + private String scope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); + } + return requestedCustomer; + } + return String.valueOf(user.tenantId()); + } +} 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 new file mode 100644 index 000000000..7ade7f61a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java @@ -0,0 +1,107 @@ +package com.baiyue.lastleg.inboundplan.pda.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; +import com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses; +import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService; +import jakarta.validation.Valid; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/inbound-plans/{planNo}/pda") +public class InboundPdaController { + public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; + @Resource private InboundPdaWorkflowService service; + + @PostMapping("/push") + public ApiResponse push(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @RequestParam(required = false) String customerId, + @Valid @RequestBody PdaRequests.Push request) { + requireOperate(user); + return ApiResponse.success(service.push(scope(user, customerId), planNo, request)); + } + + @GetMapping + public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @RequestParam(required = false) String customerId) { + return ApiResponse.success(service.task(scope(user, customerId), planNo)); + } + + @PostMapping("/allocations/{allocationId}/count") + public ApiResponse count(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @PathVariable long allocationId, + @RequestParam(required = false) String customerId, + @Valid @RequestBody PdaRequests.PackageOperation request) { + requireOperate(user); + return ApiResponse.success(service.count(scope(user, customerId), planNo, allocationId, request)); + } + + @PostMapping("/allocations/{allocationId}/pick") + public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @PathVariable long allocationId, + @RequestParam(required = false) String customerId, + @Valid @RequestBody PdaRequests.PackageOperation request) { + requireOperate(user); + return ApiResponse.success(service.pick(scope(user, customerId), planNo, allocationId, request)); + } + + @GetMapping("/allocations/{allocationId}/merge-candidates") + public ApiResponse> candidates( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, + @PathVariable long allocationId, @RequestParam(required = false) String customerId) { + return ApiResponse.success(service.mergeCandidates(scope(user, customerId), planNo, allocationId)); + } + + @PostMapping("/allocations/{allocationId}/merge") + public ApiResponse merge(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @PathVariable long allocationId, + @RequestParam(required = false) String customerId, + @Valid @RequestBody PdaRequests.ConfirmMerge request) { + requireOperate(user); + return ApiResponse.success(service.merge(scope(user, customerId), planNo, allocationId, request)); + } + + @PostMapping("/complete") + public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @RequestParam(required = false) String customerId) { + requireOperate(user); + return ApiResponse.success(service.complete(scope(user, customerId), planNo)); + } + + private String scope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); + } + return requestedCustomer; + } + return String.valueOf(user.tenantId()); + } + + private void requireOperate(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required"); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java new file mode 100644 index 000000000..3a1d3880b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.inboundplan.pda.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; + +public final class PdaRequests { + private PdaRequests() {} + + public record Push(@NotBlank String idempotencyKey, String operatorId, String operatorName) {} + + public record Assign(@Positive long warehouseId, @Positive long palletId, @Positive long locationId, + @NotBlank String idempotencyKey) {} + + public record PackageOperation(@Positive int packageCount, @NotBlank String idempotencyKey, + String operatorId, String operatorName) {} + + public record ConfirmMerge(@Positive long sourceAllocationId, @Positive int packageCount, + @NotBlank String idempotencyKey, String operatorId, String operatorName) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java new file mode 100644 index 000000000..0a9b26291 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.inboundplan.pda.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public final class PdaResponses { + private PdaResponses() {} + + public record Task(long taskId, String taskNo, String planNo, String status, + LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt) {} + + public record Operation(String result, long allocationId, int requiredPackageCount, + int countedPackageCount, int pickedPackageCount, + BigDecimal actualVolumeCbm, Integer remainingPackageCount, + Integer requestedPackageCount, Integer mergePackageCount, + List candidates) {} + + public record MergeCandidate(long allocationId, long palletId, String palletNo, long lineId, + long orderId, String inboundCode, int availablePackageCount, + BigDecimal availableVolumeCbm) {} + + public record Merge(String result, String mergeGroupNo, long sourceOrderId, long targetOrderId, + int movedPackageCount, BigDecimal movedVolumeCbm, + boolean sourcePalletScrapped) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPalletMerge.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPalletMerge.java new file mode 100644 index 000000000..0e70e49bb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPalletMerge.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.inboundplan.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_inbound_pallet_merge") +public class InboundPalletMerge { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private Long taskId; + private String mergeGroupNo; + private Long sourcePalletId; + private Long targetPalletId; + private Long sourceAllocationId; + private Long targetAllocationId; + private Long sourceLineId; + private Long targetLineId; + private Long sourceOrderId; + private Long targetOrderId; + private Integer movedPackageCount; + private BigDecimal movedVolumeCbm; + private Boolean sourcePalletScrapped; + private String status; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaOperation.java new file mode 100644 index 000000000..2a95ef792 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaOperation.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.inboundplan.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_inbound_pda_operation") +public class InboundPdaOperation { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private Long taskId; + private Long allocationId; + private String action; + private String idempotencyKey; + private String requestHash; + private String requestJson; + private String resultJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java new file mode 100644 index 000000000..753cd2fe2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.inboundplan.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_inbound_pda_task") +public class InboundPdaTask { + @TableId(type = IdType.INPUT) + private Long id; + private Long planId; + private String customerId; + private String taskNo; + private String status; + private String operatorId; + private String operatorName; + private LocalDateTime pushedAt; + private LocalDateTime startedAt; + private LocalDateTime completedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Integer version; +} 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 new file mode 100644 index 000000000..814967824 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.inboundplan.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** Immutable inbound-order/pallet package receipt created when a PDA task completes. */ +@Data +@TableName("ll_inbound_stock_receipt") +public class InboundStockReceipt { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private Long planId; + private Long taskId; + private Long allocationId; + private Long lineId; + private Long orderId; + private String inboundCode; + private Long warehousePalletId; + private String palletNo; + private Long warehouseLocationId; + private String warehouseLocationCode; + private String batchNo; + private Long receivedPackages; + private Long inventoryBalanceId; + private Long inventoryPositionId; + private String inventoryOperationKey; + private LocalDateTime receivedAt; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java new file mode 100644 index 000000000..1a5ebcf0c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -0,0 +1,89 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + +import java.util.Map; + +@Component +public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { + @Resource private RestClient.Builder restClientBuilder; + + @Value("${baiyue.warehouse.base-url:http://127.0.0.1:9305}") + private String baseUrl; + + @Value("${baiyue.security.internal-service-token:}") + private String internalToken; + + private RestClient client; + + @PostConstruct + void initializeClient() { + this.client = restClientBuilder.baseUrl(baseUrl).build(); + } + + @Override + public WarehousePalletReference ensureDropshipPallet(String customerId, long warehouseId, String businessId) { + WarehousePalletResponse response = client.post().uri("/internal/v1/warehouse-pallets/ensure-dropship") + .header("X-Internal-Service-Token", internalToken) + .body(Map.of("customerId", Long.parseLong(customerId), "warehouseId", warehouseId, "businessId", businessId)) + .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalStateException("Warehouse rejected dropship pallet: HTTP " + result.getStatusCode()); + }).body(WarehousePalletResponse.class); + if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty pallet response"); + return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().locationId()); + } + + @Override + public long reserve(String customerId, long warehouseId, long palletId, long locationId, + String businessType, String businessId, String reservationKey) { + ReserveEnvelope response = client.post() + .uri("/internal/v1/warehouse-resource-locks") + .header("X-Internal-Service-Token", internalToken) + .body(Map.of("customerId", Long.parseLong(customerId), "warehouseId", warehouseId, + "palletId", palletId, "locationId", locationId, "businessType", businessType, + "businessId", businessId, "reservationKey", reservationKey)) + .retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalStateException("Warehouse rejected pallet reservation: HTTP " + result.getStatusCode()); + }) + .body(ReserveEnvelope.class); + if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty lock response"); + return response.data().lockId(); + } + + @Override + public void scrap(String customerId, long resourceLockId) { + client.post() + .uri("/internal/v1/warehouse-resource-locks/{lockId}/scrap", resourceLockId) + .header("X-Internal-Service-Token", internalToken) + .body(Map.of("customerId", Long.parseLong(customerId))) + .retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { + throw new IllegalStateException("Warehouse rejected pallet scrap: HTTP " + response.getStatusCode()); + }) + .toBodilessEntity(); + } + + @Override + public void scrapPallet(String customerId, long palletId) { + client.post() + .uri("/internal/v1/warehouse-resource-locks/pallets/{palletId}/scrap", palletId) + .header("X-Internal-Service-Token", internalToken) + .body(Map.of("customerId", Long.parseLong(customerId))) + .retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { + throw new IllegalStateException("Warehouse rejected pallet scrap: HTTP " + response.getStatusCode()); + }) + .toBodilessEntity(); + } + + private record WarehousePalletResponse(WarehousePalletData data) {} + private record WarehousePalletData(long id, String palletCode, long locationId) {} + private record ReserveEnvelope(ReserveData data) {} + private record ReserveData(long lockId) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java new file mode 100644 index 000000000..ada468ece --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +public interface WarehouseResourceGateway { + WarehousePalletReference ensureDropshipPallet(String customerId, long warehouseId, String businessId); + long reserve(String customerId, long warehouseId, long palletId, long locationId, + String businessType, String businessId, String reservationKey); + void scrap(String customerId, long resourceLockId); + void scrapPallet(String customerId, long palletId); + + record WarehousePalletReference(long palletId, String palletNo, long locationId) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPalletMergeMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPalletMergeMapper.java new file mode 100644 index 000000000..515ce95e2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPalletMergeMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundPalletMergeMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaOperationMapper.java new file mode 100644 index 000000000..ac27ae96e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaOperationMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface InboundPdaOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_inbound_pda_operation WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + InboundPdaOperation findByKey(@Param("customerId") String customerId, @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaTaskMapper.java new file mode 100644 index 000000000..b144e7d55 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaTaskMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface InboundPdaTaskMapper extends BaseMapper { + @Select("SELECT * FROM ll_inbound_pda_task WHERE customer_id=#{customerId} AND plan_id=#{planId} LIMIT 1 FOR UPDATE") + InboundPdaTask lockByPlan(@Param("customerId") String customerId, @Param("planId") Long planId); + + @Select("SELECT * FROM ll_inbound_pda_task WHERE customer_id=#{customerId} AND plan_id=#{planId} LIMIT 1") + InboundPdaTask findByPlan(@Param("customerId") String customerId, @Param("planId") Long planId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundStockReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundStockReceiptMapper.java new file mode 100644 index 000000000..bcde3f936 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundStockReceiptMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +public interface InboundStockReceiptMapper extends BaseMapper { + @Select("SELECT * FROM ll_inbound_stock_receipt WHERE customer_id=#{customerId} AND task_id=#{taskId} " + + "ORDER BY allocation_id") + List listTask(@Param("customerId") String customerId, @Param("taskId") Long taskId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java new file mode 100644 index 000000000..e39b826e6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java @@ -0,0 +1,79 @@ +package com.baiyue.lastleg.inboundplan.pda.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPalletMergeMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPdaOperationMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPdaTaskMapper; +import org.springframework.stereotype.Repository; +import jakarta.annotation.Resource; + +import java.util.List; + +@Repository +public class PdaWorkflowRepository { + @Resource private InboundPlanMapper plans; + @Resource private InboundPlanLineMapper lines; + @Resource private InboundPlanPalletMapper pallets; + @Resource private InboundPlanPalletAllocationMapper allocations; + @Resource private InboundPdaTaskMapper tasks; + @Resource private InboundPdaOperationMapper operations; + @Resource private InboundPalletMergeMapper merges; + + public InboundPlan lockPlan(String customerId, String planNo) { + return plans.selectOne(new LambdaQueryWrapper() + .eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE")); + } + + public InboundPdaTask lockTask(String customerId, Long planId) { return tasks.lockByPlan(customerId, planId); } + public InboundPdaTask task(String customerId, Long planId) { return tasks.findByPlan(customerId, planId); } + public InboundPdaOperation operation(String customerId, String key) { return operations.findByKey(customerId, key); } + + public InboundPlanPalletAllocation lockAllocation(Long planId, Long allocationId) { + return allocations.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getPlanId, planId) + .eq(InboundPlanPalletAllocation::getId, allocationId).last("FOR UPDATE")); + } + + public InboundPlanPallet lockPallet(Long planId, Long palletId) { + return pallets.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getPlanId, planId).eq(InboundPlanPallet::getId, palletId).last("FOR UPDATE")); + } + + public InboundPlanLine line(Long planId, Long lineId) { + return lines.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanLine::getPlanId, planId).eq(InboundPlanLine::getId, lineId)); + } + + public List lines(Long planId) { + return lines.selectList(new LambdaQueryWrapper().eq(InboundPlanLine::getPlanId, planId)); + } + + public List pallets(Long planId) { + return pallets.selectList(new LambdaQueryWrapper().eq(InboundPlanPallet::getPlanId, planId)); + } + + public List allocations(Long planId) { + return allocations.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getPlanId, planId)); + } + + public void insert(InboundPdaTask value) { tasks.insert(value); } + public void insert(InboundPdaOperation value) { operations.insert(value); } + public void insert(InboundPalletMerge value) { merges.insert(value); } + public void insert(InboundPlanPalletAllocation value) { allocations.insert(value); } + public void update(InboundPlan value) { plans.updateById(value); } + public void update(InboundPdaTask value) { tasks.updateById(value); } + public void update(InboundPlanPallet value) { pallets.updateById(value); } + public void update(InboundPlanPalletAllocation value) { allocations.updateById(value); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java new file mode 100644 index 000000000..680e43d57 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -0,0 +1,532 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; +import com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@Service +public class InboundPdaWorkflowService { + private static final BigDecimal ZERO_VOLUME = new BigDecimal("0.0000"); + + @Resource private PdaWorkflowRepository repository; + @Resource private WarehouseResourceGateway warehouse; + @Resource private InboundStockReceiptService stockReceipts; + @Resource private ObjectMapper sourceObjectMapper; + + private ObjectMapper objectMapper; + + @PostConstruct + void initializeObjectMapper() { + this.objectMapper = sourceObjectMapper.copy() + .configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true) + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + } + + @Transactional + public PdaResponses.Task push(String customerId, String planNo, PdaRequests.Push request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaOperation replay = replay(customerId, "PUSH", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Task.class); + + List pallets = repository.pallets(plan.getId()); + if (pallets.isEmpty() || pallets.stream().anyMatch(this::isNotAssigned)) { + throw conflict("Every planned pallet must have an assigned and locked warehouse pallet/location"); + } + InboundPdaTask task = repository.lockTask(customerId, plan.getId()); + LocalDateTime now = LocalDateTime.now(); + if (task == null) { + task = new InboundPdaTask(); + task.setId(IdWorker.getId()); + task.setPlanId(plan.getId()); + task.setCustomerId(customerId); + task.setTaskNo("PDA" + task.getId()); + task.setStatus("PDA_PENDING"); + task.setOperatorId(request.operatorId()); + task.setOperatorName(request.operatorName()); + task.setPushedAt(now); + task.setCreatedAt(now); + task.setUpdatedAt(now); + task.setVersion(0); + repository.insert(task); + } + plan.setStatus("PDA_PENDING"); + plan.setUpdatedAt(now); + repository.update(plan); + PdaResponses.Task result = task(task, planNo); + saveOperation(customerId, task.getId(), null, "PUSH", request.idempotencyKey(), request, result); + return result; + } + + @Transactional + public void assign(String customerId, String planNo, long planPalletId, PdaRequests.Assign request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPlanPallet pallet = requirePallet(plan.getId(), planPalletId); + validateDestinationWarehouse(pallet, request.warehouseId()); + if (pallet.getWarehouseResourceLockId() != null) { + if (Objects.equals(pallet.getWarehousePalletId(), request.palletId()) + && Objects.equals(pallet.getWarehouseLocationId(), request.locationId())) return; + throw conflict("Planned pallet is already assigned to another warehouse resource"); + } + String businessId = planNo + ":" + planPalletId; + long lockId = warehouse.reserve(customerId, request.warehouseId(), request.palletId(), request.locationId(), + "INBOUND_PLAN_PALLET", businessId, request.idempotencyKey()); + pallet.setWarehousePalletId(request.palletId()); + pallet.setWarehouseLocationId(request.locationId()); + pallet.setWarehouseResourceLockId(lockId); + pallet.setAssignmentStatus("ASSIGNED"); + pallet.setLockedAt(LocalDateTime.now()); + repository.update(pallet); + List pallets = repository.pallets(plan.getId()); + if (!pallets.isEmpty() && pallets.stream().allMatch(value -> !isNotAssigned(value))) { + plan.setStatus("ASSIGNED"); + plan.setUpdatedAt(LocalDateTime.now()); + repository.update(plan); + } + } + + @Transactional + public PdaResponses.Task task(String customerId, String planNo) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaTask task = repository.task(customerId, plan.getId()); + if (task == null) throw notFound("PDA task not found"); + return task(task, planNo); + } + + @Transactional + public PdaResponses.Operation count(String customerId, String planNo, long allocationId, + PdaRequests.PackageOperation request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaOperation replay = replay(customerId, "COUNT", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Operation.class); + InboundPdaTask task = requireActiveTask(customerId, plan); + InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId); + int picked = value(allocation.getPickedPackageCount()); + if (request.packageCount() < picked) { + throw conflict("Counted package count cannot be less than already picked packages"); + } + if (request.packageCount() > value(allocation.getPlannedPackageCount())) { + throw conflict("Counted package count cannot exceed this pallet allocation"); + } + allocation.setCountedPackageCount(request.packageCount()); + allocation.setStatus(request.packageCount() == value(allocation.getPlannedPackageCount()) + ? "COUNTED" : "COUNTING"); + bump(allocation); + repository.update(allocation); + startPicking(task, plan, request.operatorId(), request.operatorName()); + InboundPlanLine line = requireLine(plan.getId(), allocation.getLineId()); + PdaResponses.Operation result = operation("COUNTED", allocation, line, List.of(), null, null, null); + saveOperation(customerId, task.getId(), allocationId, "COUNT", request.idempotencyKey(), request, result); + return result; + } + + @Transactional + public PdaResponses.Operation pick(String customerId, String planNo, long allocationId, + PdaRequests.PackageOperation request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Operation.class); + InboundPdaTask task = requireActiveTask(customerId, plan); + InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId); + InboundPlanLine line = requireLine(plan.getId(), allocation.getLineId()); + int picked = value(allocation.getPickedPackageCount()); + int countedRemaining = value(allocation.getCountedPackageCount()) - picked; + if (countedRemaining <= 0) throw conflict("Count this pallet allocation before picking"); + int plannedRemaining = value(allocation.getPlannedPackageCount()) - picked; + if (request.packageCount() > plannedRemaining) { + int mergeCount = request.packageCount() - Math.max(0, plannedRemaining); + List candidates = candidates(plan, allocation, mergeCount); + PdaResponses.Operation result = operation("MERGE_CONFIRMATION_REQUIRED", allocation, line, + candidates, plannedRemaining, request.packageCount(), mergeCount); + saveOperation(customerId, task.getId(), allocationId, "PICK", request.idempotencyKey(), request, result); + return result; + } + if (request.packageCount() > countedRemaining) { + throw conflict("Picked package count cannot exceed the counted remaining packages"); + } + BigDecimal perPackage = perPackageVolume(line); + int newPicked = picked + request.packageCount(); + allocation.setPickedPackageCount(newPicked); + allocation.setActualVolumeCbm(perPackage.multiply(BigDecimal.valueOf(newPicked)).setScale(4, RoundingMode.HALF_UP)); + allocation.setStatus(newPicked == value(allocation.getPlannedPackageCount()) ? "PICKED" : "PICKING"); + bump(allocation); + repository.update(allocation); + InboundPlanPallet pallet = requirePallet(plan.getId(), allocation.getPalletId()); + pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + request.packageCount()); + pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()).add( + perPackage.multiply(BigDecimal.valueOf(request.packageCount()))).setScale(4, RoundingMode.HALF_UP)); + repository.update(pallet); + startPicking(task, plan, request.operatorId(), request.operatorName()); + PdaResponses.Operation result = operation("PICKED", allocation, line, List.of(), null, null, null); + saveOperation(customerId, task.getId(), allocationId, "PICK", request.idempotencyKey(), request, result); + return result; + } + + @Transactional + public List mergeCandidates(String customerId, String planNo, + long allocationId) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId); + return candidates(plan, allocation, 1); + } + + @Transactional + public PdaResponses.Merge merge(String customerId, String planNo, long targetAllocationId, + PdaRequests.ConfirmMerge request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaOperation replay = replay(customerId, "MERGE", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Merge.class); + InboundPdaTask task = requireActiveTask(customerId, plan); + if (request.sourceAllocationId() == targetAllocationId) throw conflict("Source and target allocations differ"); + InboundPlanPalletAllocation target = requireAllocation(plan.getId(), targetAllocationId); + InboundPlanPalletAllocation source = requireAllocation(plan.getId(), request.sourceAllocationId()); + InboundPlanPallet targetPallet = requirePallet(plan.getId(), target.getPalletId()); + InboundPlanPallet sourcePallet = requirePallet(plan.getId(), source.getPalletId()); + if (Objects.equals(targetPallet.getId(), sourcePallet.getId())) throw conflict("Source and target pallets differ"); + if (!sameWarehouse(targetPallet, sourcePallet)) throw conflict("Pallet merge requires the same destination warehouse"); + if (value(source.getPickedPackageCount()) < request.packageCount()) { + throw conflict("Source pallet does not contain enough picked packages"); + } + InboundPlanLine sourceLine = requireLine(plan.getId(), source.getLineId()); + InboundPlanLine targetLine = requireLine(plan.getId(), target.getLineId()); + BigDecimal movedVolume = perPackageVolume(sourceLine) + .multiply(BigDecimal.valueOf(request.packageCount())).setScale(4, RoundingMode.HALF_UP); + if (value(sourcePallet.getActualPackageCount()) < request.packageCount()) { + throw conflict("Source pallet actual package count is insufficient"); + } + if (targetPallet.getCapacityCbm() != null + && volume(targetPallet.getActualVolumeCbm()).add(movedVolume).compareTo(targetPallet.getCapacityCbm()) > 0) { + throw conflict("Merged volume exceeds target pallet capacity"); + } + + moveSourceAllocation(plan.getId(), source, targetPallet.getId(), request.packageCount(), movedVolume); + sourcePallet.setActualPackageCount(value(sourcePallet.getActualPackageCount()) - request.packageCount()); + sourcePallet.setActualVolumeCbm(nonNegative(volume(sourcePallet.getActualVolumeCbm()).subtract(movedVolume))); + targetPallet.setActualPackageCount(value(targetPallet.getActualPackageCount()) + request.packageCount()); + targetPallet.setActualVolumeCbm(volume(targetPallet.getActualVolumeCbm()).add(movedVolume)); + boolean scrapped = sourcePallet.getActualPackageCount() == 0; + if (scrapped) { + if (sourcePallet.getWarehouseResourceLockId() == null) { + throw conflict("Source pallet has no warehouse resource lock"); + } + warehouse.scrap(customerId, sourcePallet.getWarehouseResourceLockId()); + sourcePallet.setAssignmentStatus("SCRAPPED"); + sourcePallet.setWarehouseLocationId(null); + sourcePallet.setWarehouseResourceLockId(null); + } + repository.update(sourcePallet); + repository.update(targetPallet); + + String groupNo = "PMG" + IdWorker.getId(); + InboundPalletMerge merge = new InboundPalletMerge(); + merge.setId(IdWorker.getId()); + merge.setCustomerId(customerId); + merge.setTaskId(task.getId()); + merge.setMergeGroupNo(groupNo); + merge.setSourcePalletId(sourcePallet.getId()); + merge.setTargetPalletId(targetPallet.getId()); + merge.setSourceAllocationId(source.getId()); + merge.setTargetAllocationId(target.getId()); + merge.setSourceLineId(sourceLine.getId()); + merge.setTargetLineId(targetLine.getId()); + merge.setSourceOrderId(sourceLine.getOrderId()); + merge.setTargetOrderId(targetLine.getOrderId()); + merge.setMovedPackageCount(request.packageCount()); + merge.setMovedVolumeCbm(movedVolume); + merge.setSourcePalletScrapped(scrapped); + merge.setStatus("CONFIRMED"); + merge.setCreatedAt(LocalDateTime.now()); + repository.insert(merge); + PdaResponses.Merge result = new PdaResponses.Merge("MERGED", groupNo, sourceLine.getOrderId(), + targetLine.getOrderId(), request.packageCount(), movedVolume, scrapped); + saveOperation(customerId, task.getId(), targetAllocationId, "MERGE", request.idempotencyKey(), request, result); + return result; + } + + @Transactional + public PdaResponses.Task complete(String customerId, String planNo) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaTask task = repository.lockTask(customerId, plan.getId()); + if (task == null) throw conflict("Push the inbound plan to PDA before operating"); + if ("COMPLETED".equals(task.getStatus())) return task(task, planNo); + Map pickedByLine = new HashMap<>(); + List allocations = repository.allocations(plan.getId()); + for (InboundPlanPalletAllocation allocation : allocations) { + pickedByLine.merge(allocation.getLineId(), value(allocation.getPickedPackageCount()), Integer::sum); + } + List lines = repository.lines(plan.getId()); + if (lines.isEmpty()) throw conflict("Inbound plan has no lines"); + for (InboundPlanLine line : lines) { + if (pickedByLine.getOrDefault(line.getId(), 0) != value(line.getRequiredPackageCount())) { + throw conflict("All line picked package totals must exactly equal required package totals"); + } + } + LocalDateTime now = LocalDateTime.now(); + stockReceipts.stockIn(customerId, plan, task, lines, allocations, repository.pallets(plan.getId()), now); + task.setStatus("COMPLETED"); + task.setCompletedAt(now); + task.setUpdatedAt(now); + task.setVersion(value(task.getVersion()) + 1); + repository.update(task); + plan.setStatus("COMPLETED"); + plan.setUpdatedAt(now); + repository.update(plan); + return task(task, planNo); + } + + private void moveSourceAllocation(Long planId, InboundPlanPalletAllocation source, Long targetPalletId, + int packageCount, BigDecimal movedVolume) { + source.setPickedPackageCount(value(source.getPickedPackageCount()) - packageCount); + source.setCountedPackageCount(Math.max(source.getPickedPackageCount(), + value(source.getCountedPackageCount()) - packageCount)); + source.setActualVolumeCbm(nonNegative(volume(source.getActualVolumeCbm()).subtract(movedVolume))); + source.setStatus(source.getPickedPackageCount() == 0 ? "MERGED_OUT" : "PICKED"); + bump(source); + repository.update(source); + + InboundPlanPalletAllocation moved = repository.allocations(planId).stream() + .filter(value -> Objects.equals(value.getLineId(), source.getLineId())) + .filter(value -> Objects.equals(value.getPalletId(), targetPalletId)) + .findFirst().orElse(null); + if (moved == null) { + moved = new InboundPlanPalletAllocation(); + moved.setId(IdWorker.getId()); + moved.setPlanId(planId); + moved.setLineId(source.getLineId()); + moved.setPalletId(targetPalletId); + moved.setAllocatedVolumeCbm(ZERO_VOLUME); + moved.setPlannedPackageCount(0); + moved.setCountedPackageCount(packageCount); + moved.setPickedPackageCount(packageCount); + moved.setActualVolumeCbm(movedVolume); + moved.setStatus("MERGED_IN"); + moved.setVersion(0); + repository.insert(moved); + } else { + moved.setCountedPackageCount(value(moved.getCountedPackageCount()) + packageCount); + moved.setPickedPackageCount(value(moved.getPickedPackageCount()) + packageCount); + moved.setActualVolumeCbm(volume(moved.getActualVolumeCbm()).add(movedVolume)); + moved.setStatus("MERGED_IN"); + bump(moved); + repository.update(moved); + } + } + + private List candidates(InboundPlan plan, + InboundPlanPalletAllocation target, + int needed) { + Map palletById = new HashMap<>(); + for (InboundPlanPallet pallet : repository.pallets(plan.getId())) palletById.put(pallet.getId(), pallet); + Map lineById = new HashMap<>(); + for (InboundPlanLine line : repository.lines(plan.getId())) lineById.put(line.getId(), line); + InboundPlanPallet targetPallet = palletById.get(target.getPalletId()); + if (targetPallet == null) throw notFound("Target pallet not found"); + List result = new ArrayList<>(); + for (InboundPlanPalletAllocation candidate : repository.allocations(plan.getId())) { + if (Objects.equals(candidate.getPalletId(), target.getPalletId())) continue; + int available = value(candidate.getPickedPackageCount()); + if (available <= 0) continue; + InboundPlanPallet pallet = palletById.get(candidate.getPalletId()); + InboundPlanLine line = lineById.get(candidate.getLineId()); + if (pallet == null || line == null || !sameWarehouse(targetPallet, pallet)) continue; + if (targetPallet.getCapacityCbm() != null) { + BigDecimal remainingVolume = targetPallet.getCapacityCbm() + .subtract(volume(targetPallet.getActualVolumeCbm())); + BigDecimal packageVolume = perPackageVolume(line); + if (packageVolume.signum() > 0) { + int capacityPackages = remainingVolume.signum() <= 0 ? 0 + : remainingVolume.divide(packageVolume, 0, RoundingMode.DOWN).intValue(); + available = Math.min(available, capacityPackages); + if (available <= 0) continue; + } + } + result.add(new PdaResponses.MergeCandidate(candidate.getId(), pallet.getId(), pallet.getPalletNo(), + line.getId(), line.getOrderId(), line.getInboundCode(), available, + perPackageVolume(line).multiply(BigDecimal.valueOf(available)).setScale(4, RoundingMode.HALF_UP))); + } + result.sort(Comparator.comparingInt(PdaResponses.MergeCandidate::availablePackageCount).reversed()); + return result; + } + + private boolean sameWarehouse(InboundPlanPallet left, InboundPlanPallet right) { + String leftKey = left.getDestinationWarehouseId() != null + ? left.getDestinationWarehouseId() : left.getDestinationWarehouseCode(); + String rightKey = right.getDestinationWarehouseId() != null + ? right.getDestinationWarehouseId() : right.getDestinationWarehouseCode(); + return leftKey != null && leftKey.equals(rightKey); + } + + private void validateDestinationWarehouse(InboundPlanPallet pallet, long warehouseId) { + String destinationId = pallet.getDestinationWarehouseId(); + if (destinationId == null || destinationId.isBlank()) return; + try { + if (Long.parseLong(destinationId) != warehouseId) { + throw conflict("Warehouse resource must belong to the planned destination warehouse"); + } + } catch (NumberFormatException exception) { + throw conflict("Planned destination warehouse ID is not a numeric warehouse resource ID"); + } + } + + private void startPicking(InboundPdaTask task, InboundPlan plan, String operatorId, String operatorName) { + LocalDateTime now = LocalDateTime.now(); + if (task.getStartedAt() == null) task.setStartedAt(now); + task.setStatus("PICKING"); + task.setOperatorId(operatorId); + task.setOperatorName(operatorName); + task.setUpdatedAt(now); + task.setVersion(value(task.getVersion()) + 1); + repository.update(task); + plan.setStatus("PICKING"); + plan.setUpdatedAt(now); + repository.update(plan); + } + + private boolean isNotAssigned(InboundPlanPallet pallet) { + return pallet.getWarehousePalletId() == null || pallet.getWarehouseLocationId() == null + || pallet.getWarehouseResourceLockId() == null + || !("ASSIGNED".equals(pallet.getAssignmentStatus()) || "LOCKED".equals(pallet.getAssignmentStatus())); + } + + private InboundPlan requirePlan(String customerId, String planNo) { + InboundPlan plan = repository.lockPlan(customerId, planNo); + if (plan == null) throw notFound("Inbound plan not found"); + return plan; + } + + private InboundPdaTask requireActiveTask(String customerId, InboundPlan plan) { + InboundPdaTask task = repository.lockTask(customerId, plan.getId()); + if (task == null) throw conflict("Push the inbound plan to PDA before operating"); + if ("COMPLETED".equals(task.getStatus())) throw conflict("PDA task is already completed"); + return task; + } + + private InboundPlanPalletAllocation requireAllocation(Long planId, Long id) { + InboundPlanPalletAllocation value = repository.lockAllocation(planId, id); + if (value == null) throw notFound("Pallet allocation not found"); + return value; + } + + private InboundPlanPallet requirePallet(Long planId, Long id) { + InboundPlanPallet value = repository.lockPallet(planId, id); + if (value == null) throw notFound("Planned pallet not found"); + return value; + } + + private InboundPlanLine requireLine(Long planId, Long id) { + InboundPlanLine value = repository.line(planId, id); + if (value == null) throw notFound("Inbound plan line not found"); + return value; + } + + private PdaResponses.Operation operation(String result, InboundPlanPalletAllocation allocation, + InboundPlanLine line, + List candidates, + Integer remaining, Integer requested, Integer merge) { + return new PdaResponses.Operation(result, allocation.getId(), value(line.getRequiredPackageCount()), + value(allocation.getCountedPackageCount()), value(allocation.getPickedPackageCount()), + volume(allocation.getActualVolumeCbm()), remaining, requested, merge, candidates); + } + + private PdaResponses.Task task(InboundPdaTask task, String planNo) { + return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(), + task.getPushedAt(), task.getStartedAt(), task.getCompletedAt()); + } + + private BigDecimal perPackageVolume(InboundPlanLine line) { + int required = value(line.getRequiredPackageCount()); + if (required <= 0) throw conflict("Inbound line required package count must be positive"); + return volume(line.getVolumeCbm()).divide(BigDecimal.valueOf(required), 8, RoundingMode.HALF_UP); + } + + private InboundPdaOperation replay(String customerId, String action, String key, Object request) { + InboundPdaOperation operation = repository.operation(customerId, key); + if (operation == null) return null; + if (!action.equals(operation.getAction()) || !hash(request).equals(operation.getRequestHash())) { + throw conflict("Idempotency key was already used with a different request"); + } + return operation; + } + + private void saveOperation(String customerId, Long taskId, Long allocationId, String action, + String key, Object request, Object result) { + InboundPdaOperation operation = new InboundPdaOperation(); + operation.setId(IdWorker.getId()); + operation.setCustomerId(customerId); + operation.setTaskId(taskId); + operation.setAllocationId(allocationId); + operation.setAction(action); + operation.setIdempotencyKey(key); + operation.setRequestHash(hash(request)); + operation.setRequestJson(write(request)); + operation.setResultJson(write(result)); + operation.setCreatedAt(LocalDateTime.now()); + repository.insert(operation); + } + + private String hash(Object value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private String write(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot serialize PDA operation", exception); + } + } + + private T read(InboundPdaOperation operation, Class type) { + try { + return objectMapper.readValue(operation.getResultJson(), type); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot read persisted PDA operation result", exception); + } + } + + private void bump(InboundPlanPalletAllocation allocation) { + allocation.setVersion(value(allocation.getVersion()) + 1); + } + + private static int value(Integer value) { return value == null ? 0 : value; } + private static BigDecimal volume(BigDecimal value) { return value == null ? ZERO_VOLUME : value; } + private static BigDecimal nonNegative(BigDecimal value) { return value.signum() < 0 ? ZERO_VOLUME : value.setScale(4, RoundingMode.HALF_UP); } + 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/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java new file mode 100644 index 000000000..a2ec49f0f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -0,0 +1,193 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper; +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.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** Converts final PDA package counts into inbound-order/pallet inventory. */ +@Service +public class InboundStockReceiptService { + @Resource private LastlegOrderMapper orders; + @Resource private InboundStockReceiptMapper receipts; + @Resource private InventoryCommandService inventory; + @Resource private DropshipOrderService dropshipOrders; + @Resource private LastlegOrderStatusHistoryMapper orderHistories; + + @Transactional + public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task, + List lines, List allocations, + List pallets, LocalDateTime receivedAt) { + Map orderById = orders.selectBatchIds(lines.stream() + .map(InboundPlanLine::getOrderId).collect(Collectors.toSet())).stream() + .filter(order -> customerId.equals(order.getCustomerId())) + .collect(Collectors.toMap(LastlegOrder::getId, order -> order)); + Map palletById = pallets.stream() + .collect(Collectors.toMap(InboundPlanPallet::getId, pallet -> pallet)); + Map> allocationsByLine = allocations.stream() + .filter(allocation -> value(allocation.getPickedPackageCount()) > 0) + .sorted(Comparator.comparing(InboundPlanPalletAllocation::getId)) + .collect(Collectors.groupingBy(InboundPlanPalletAllocation::getLineId)); + + Set expectedAllocationIds = allocationsByLine.values().stream().flatMap(List::stream) + .map(InboundPlanPalletAllocation::getId).collect(Collectors.toSet()); + List existing = receipts.listTask(customerId, task.getId()); + if (!existing.isEmpty()) { + Set receivedAllocationIds = existing.stream().map(InboundStockReceipt::getAllocationId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + if (receivedAllocationIds.equals(expectedAllocationIds)) { + activateDropship(customerId, task.getId(), orderById, existing); + completeOrders(customerId, plan, orderById, receivedAt); + return; + } + throw conflict("PDA stock-in receipts are incomplete; repair the task receipts before retrying"); + } + + List created = new java.util.ArrayList<>(); + for (InboundPlanLine line : lines) { + LastlegOrder order = orderById.get(line.getOrderId()); + if (order == null) throw conflict("Inbound line tail order is missing or outside the tenant"); + List lineAllocations = allocationsByLine + .getOrDefault(line.getId(), List.of()); + long pickedPackages = lineAllocations.stream() + .mapToLong(allocation -> value(allocation.getPickedPackageCount())).sum(); + if (pickedPackages != value(line.getRequiredPackageCount())) { + throw conflict("PDA picked packages must equal the inbound order package total"); + } + for (InboundPlanPalletAllocation allocation : lineAllocations) { + created.add(persist(customerId, plan, task, line, allocation, + requirePallet(palletById, allocation), order, receivedAt)); + } + } + activateDropship(customerId, task.getId(), orderById, created); + completeOrders(customerId, plan, orderById, receivedAt); + } + + private void completeOrders(String customerId, InboundPlan plan, Map orderById, + LocalDateTime completedAt) { + for (LastlegOrder snapshot : orderById.values()) { + LastlegOrder order = orders.lockById(customerId, snapshot.getId()); + if (order == null) throw conflict("Inbound tail-leg order disappeared while completing stock-in"); + if ("INBOUND_COMPLETED".equals(order.getCurrentStatus())) continue; + String before = order.getCurrentStatus(); + order.setCurrentStatus("INBOUND_COMPLETED"); + order.setSubOrderStatus("INBOUND_PLAN:COMPLETED"); + order.setUpdatedAt(completedAt); + orders.updateById(order); + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); + history.setOrderId(order.getId()); + history.setFromStatus(before); + history.setToStatus("INBOUND_COMPLETED"); + history.setEventId("PDA_INBOUND_COMPLETE:" + plan.getPlanNo()); + history.setRemark("PDA inbound completed and inventory stocked in"); + history.setCreatedAt(completedAt); + orderHistories.insert(history); + } + } + + private InboundStockReceipt persist(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line, + InboundPlanPalletAllocation allocation, InboundPlanPallet pallet, + LastlegOrder order, LocalDateTime receivedAt) { + long packages = value(allocation.getPickedPackageCount()); + 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 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(), + pallet.getWarehousePalletId(), pallet.getPalletNo(), plan.getPlanNo(), + pallet.getWarehouseLocationId(), null, packages, order.getOrderNo(), + order.getSourceEventId(), operationKey, + "PDA inbound completion for " + line.getInboundCode())); + + InboundStockReceipt receipt = new InboundStockReceipt(); + receipt.setId(IdWorker.getId()); + receipt.setCustomerId(customerId); + receipt.setPlanId(plan.getId()); + receipt.setTaskId(task.getId()); + receipt.setAllocationId(allocation.getId()); + receipt.setLineId(line.getId()); + receipt.setOrderId(order.getId()); + receipt.setInboundCode(line.getInboundCode()); + receipt.setWarehousePalletId(pallet.getWarehousePalletId()); + receipt.setPalletNo(pallet.getPalletNo()); + receipt.setWarehouseLocationId(pallet.getWarehouseLocationId()); + receipt.setBatchNo(plan.getPlanNo()); + receipt.setReceivedPackages(packages); + receipt.setInventoryBalanceId(result.balanceId()); + receipt.setInventoryPositionId(result.positionId()); + receipt.setInventoryOperationKey(operationKey); + receipt.setReceivedAt(receivedAt); + receipt.setCreatedAt(receivedAt); + receipts.insert(receipt); + return receipt; + } + + private void activateDropship(String customerId, Long taskId, Map orderById, + List stockReceipts) { + Map> receiptsByOrder = stockReceipts.stream() + .filter(receipt -> receipt.getOrderId() != null) + .collect(Collectors.groupingBy(InboundStockReceipt::getOrderId)); + for (LastlegOrder order : orderById.values()) { + if ("ONE_CLICK_DROPSHIP".equals(order.getOrderType())) { + dropshipOrders.activateAfterStockIn(customerId, order.getId(), taskId, + receiptsByOrder.getOrDefault(order.getId(), List.of())); + } + } + } + + private static InboundPlanPallet requirePallet(Map pallets, + InboundPlanPalletAllocation allocation) { + InboundPlanPallet pallet = pallets.get(allocation.getPalletId()); + if (pallet == null) throw conflict("PDA allocation pallet is missing"); + return pallet; + } + + private static String required(String value, String message) { + String normalized = trim(value); + if (normalized == null) throw conflict(message); + return normalized; + } + + private static String first(String first, String second) { + String normalized = trim(first); + return normalized == null ? trim(second) : normalized; + } + + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static long value(Integer value) { return value == null ? 0L : value.longValue(); } + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java new file mode 100644 index 000000000..6045f4a46 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java @@ -0,0 +1,180 @@ +package com.baiyue.lastleg.inboundplan.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; +import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper; +import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot; +import com.baiyue.lastleg.inboundplan.model.FbaAppointmentStatus; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +@Service +public class InboundFbaAppointmentService { + @Resource private InboundFbaAppointmentMapper appointmentMapper; + @Resource private InboundFbaAppointmentItemMapper itemMapper; + @Resource private InboundFbaAppointmentQueryMapper queryMapper; + + @Transactional + public FbaAppointmentResponse batchCreate(String customerId, String operatorId, + BatchFbaAppointmentRequest request) { + List lineIds = List.copyOf(request.lineIds()); + if (new HashSet<>(lineIds).size() != lineIds.size()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Selected inbound lines contain duplicates"); + } + String appointmentNo = requiredTrimmed(request.appointmentNo(), "Appointment number is required"); + String status = normalizeStatus(request.appointmentStatus()); + String remark = trimToNull(request.appointmentRemark()); + + InboundFbaAppointment existing = find(customerId, appointmentNo); + if (existing != null) { + return verifyReplay(existing, lineIds, request.appointmentAt(), status, remark); + } + + List lines = queryMapper.lockLines(customerId, lineIds); + if (lines.size() != lineIds.size()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "One or more selected inbound lines do not exist in this tenant"); + } + String fbaWarehouseCode = commonFbaWarehouse(lines); + + // The row locks serialize concurrent submissions selecting the same inbound lines. + existing = find(customerId, appointmentNo); + if (existing != null) { + return verifyReplay(existing, lineIds, request.appointmentAt(), status, remark); + } + + LocalDateTime now = LocalDateTime.now(); + InboundFbaAppointment appointment = new InboundFbaAppointment(); + appointment.setId(IdWorker.getId()); + appointment.setCustomerId(customerId); + appointment.setAppointmentNo(appointmentNo); + appointment.setAppointmentAt(request.appointmentAt()); + appointment.setAppointmentStatus(status); + appointment.setAppointmentRemark(remark); + appointment.setFbaWarehouseCode(fbaWarehouseCode); + appointment.setSelectedCount(lines.size()); + appointment.setCreatedBy(trimToNull(operatorId)); + appointment.setCreatedAt(now); + appointment.setUpdatedAt(now); + try { + appointmentMapper.insert(appointment); + } catch (DuplicateKeyException exception) { + InboundFbaAppointment concurrent = find(customerId, appointmentNo); + if (concurrent != null) { + return verifyReplay(concurrent, lineIds, request.appointmentAt(), status, remark); + } + throw exception; + } + + for (FbaAppointmentLineSnapshot line : lines) { + InboundFbaAppointmentItem item = new InboundFbaAppointmentItem(); + item.setId(IdWorker.getId()); + item.setAppointmentId(appointment.getId()); + item.setPlanId(line.planId()); + item.setLineId(line.lineId()); + item.setOrderId(line.orderId()); + item.setInboundCode(line.inboundCode()); + item.setFbaWarehouseCode(line.fbaWarehouseCode()); + item.setCreatedAt(now); + itemMapper.insert(item); + } + return response(appointment, items(appointment.getId())); + } + + @Transactional(readOnly = true) + public FbaAppointmentResponse detail(String customerId, String appointmentNo) { + InboundFbaAppointment appointment = find(customerId, + requiredTrimmed(appointmentNo, "Appointment number is required")); + if (appointment == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "FBA appointment not found"); + } + return response(appointment, items(appointment.getId())); + } + + private FbaAppointmentResponse verifyReplay(InboundFbaAppointment existing, List requestedLineIds, + LocalDateTime appointmentAt, String status, String remark) { + List existingItems = items(existing.getId()); + Set existingLineIds = existingItems.stream() + .map(InboundFbaAppointmentItem::getLineId).collect(java.util.stream.Collectors.toSet()); + if (!Objects.equals(existing.getAppointmentAt(), appointmentAt) + || !Objects.equals(existing.getAppointmentStatus(), status) + || !Objects.equals(existing.getAppointmentRemark(), remark) + || existingLineIds.size() != requestedLineIds.size() + || !existingLineIds.equals(new HashSet<>(requestedLineIds))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Appointment number already exists with different appointment data"); + } + return response(existing, existingItems); + } + + private String commonFbaWarehouse(List lines) { + List warehouses = lines.stream().map(FbaAppointmentLineSnapshot::fbaWarehouseCode) + .map(InboundFbaAppointmentService::trimToNull).toList(); + if (warehouses.stream().anyMatch(Objects::isNull) || new HashSet<>(warehouses).size() != 1) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Selected inbound lines must belong to the same non-empty FBA warehouse"); + } + return warehouses.get(0); + } + + private InboundFbaAppointment find(String customerId, String appointmentNo) { + return appointmentMapper.selectOne(new LambdaQueryWrapper() + .eq(InboundFbaAppointment::getCustomerId, customerId) + .eq(InboundFbaAppointment::getAppointmentNo, appointmentNo)); + } + + private List items(Long appointmentId) { + return itemMapper.selectList(new LambdaQueryWrapper() + .eq(InboundFbaAppointmentItem::getAppointmentId, appointmentId) + .orderByAsc(InboundFbaAppointmentItem::getLineId)); + } + + private FbaAppointmentResponse response(InboundFbaAppointment appointment, + List items) { + return new FbaAppointmentResponse(appointment.getId(), appointment.getCustomerId(), + appointment.getAppointmentNo(), appointment.getAppointmentAt(), appointment.getAppointmentStatus(), + appointment.getAppointmentRemark(), appointment.getFbaWarehouseCode(), appointment.getSelectedCount(), + appointment.getCreatedBy(), appointment.getCreatedAt(), appointment.getUpdatedAt(), + items.stream().map(item -> new FbaAppointmentResponse.Item(item.getId(), item.getPlanId(), + item.getLineId(), item.getOrderId(), item.getInboundCode(), item.getFbaWarehouseCode())).toList()); + } + + private static String normalizeStatus(String value) { + String normalized = requiredTrimmed(value, "Appointment status is required").toUpperCase(Locale.ROOT); + try { + return FbaAppointmentStatus.valueOf(normalized).name(); + } catch (IllegalArgumentException exception) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Appointment status must be PENDING, SUCCESS, FAILED or CANCELLED"); + } + } + + private static String requiredTrimmed(String value, String message) { + String normalized = trimToNull(value); + if (normalized == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + return normalized; + } + + private static String trimToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } +} 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 new file mode 100644 index 000000000..58570f0d0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -0,0 +1,243 @@ +package com.baiyue.lastleg.inboundplan.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanOrderMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +@Service +public class InboundPlanService { + @Resource private InboundPlanMapper planMapper; + @Resource private InboundPlanLineMapper lineMapper; + @Resource private InboundPlanPalletMapper palletMapper; + @Resource private InboundPlanPalletAllocationMapper allocationMapper; + @Resource private InboundPlanOrderMapper orderMapper; + + @Transactional + public InboundPlanResponse convert(String customerId, Long selectedOrderId) { + String containerNo = trimToNull(orderMapper.findContainerNo(customerId, selectedOrderId)); + if (containerNo == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Order does not exist in this tenant or container number is empty"); + } + + List orders = orderMapper.lockContainerOrders(customerId, containerNo); + if (orders.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Container orders not found"); + } + InboundPlan existing = planMapper.findByContainer(customerId, containerNo); + if (existing != null) { + return detail(customerId, existing); + } + validateOrders(orders); + + LocalDateTime now = LocalDateTime.now(); + InboundPlan plan = new InboundPlan(); + plan.setId(IdWorker.getId()); + plan.setPlanNo("LIP" + plan.getId()); + plan.setCustomerId(customerId); + plan.setContainerNo(containerNo); + plan.setStatus("PENDING_ASSIGNMENT"); + plan.setLineCount(orders.size()); + plan.setTotalVolumeCbm(orders.stream().map(SourceOrderSnapshot::volumeCbm) + .filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); + plan.setCreatedAt(now); + plan.setUpdatedAt(now); + + Map orderById = orders.stream() + .collect(Collectors.toMap(SourceOrderSnapshot::orderId, value -> value)); + Map lineByOrder = createLines(plan, orders); + PalletAllocator.Result allocation = PalletAllocator.allocate(orders.stream() + .map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order), + order.packageCount())) + .toList()); + plan.setPalletCount(allocation.pallets().size()); + planMapper.insert(plan); + + lineByOrder.values().forEach(lineMapper::insert); + Map persistedPallets = createPallets(plan, allocation, orderById); + createAllocations(plan, allocation, lineByOrder, persistedPallets); + + Map palletCounts = allocation.allocations().stream().collect(Collectors.groupingBy( + PalletAllocator.AllocationDraft::orderId, + Collectors.collectingAndThen(Collectors.mapping(item -> item.pallet().sequenceNo(), Collectors.toSet()), java.util.Set::size))); + for (SourceOrderSnapshot order : orders) { + int count = palletCounts.getOrDefault(order.orderId(), 0); + InboundPlanLine line = lineByOrder.get(order.orderId()); + line.setPlannedPalletCount(count); + lineMapper.updateById(line); + if (orderMapper.markPlanned(customerId, order.orderId(), plan.getPlanNo(), count, now) != 1) { + throw new IllegalStateException("Failed to update order " + order.orderId()); + } + orderMapper.insertHistory(order.orderId(), order.currentStatus(), + "Container converted to inbound plan " + plan.getPlanNo(), now); + } + return detail(customerId, plan); + } + + @Transactional(readOnly = true) + public InboundPlanResponse detailByPlanNo(String customerId, String planNo) { + InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper() + .eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo)); + if (plan == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found"); + } + return detail(customerId, plan); + } + + private Map createLines(InboundPlan plan, List orders) { + Map result = new HashMap<>(); + for (SourceOrderSnapshot order : orders) { + InboundPlanLine line = new InboundPlanLine(); + line.setId(IdWorker.getId()); + line.setPlanId(plan.getId()); + line.setOrderId(order.orderId()); + line.setInboundCode(order.inboundCode()); + line.setFbaWarehouseCode(order.fbaWarehouseCode()); + line.setDestinationWarehouseId(order.destinationWarehouseId()); + line.setDestinationWarehouseCode(order.destinationWarehouseCode()); + line.setDestinationWarehouseName(order.destinationWarehouseName()); + line.setVolumeCbm(volume(order)); + line.setRequiredPackageCount(order.packageCount()); + line.setPlannedPalletCount(0); + result.put(order.orderId(), line); + } + return result; + } + + private Map createPallets( + InboundPlan plan, PalletAllocator.Result allocation, Map orderById) { + Map warehouseSamples = orderById.values().stream() + .collect(Collectors.toMap(this::warehouseKey, value -> value, (left, right) -> left)); + Map result = new HashMap<>(); + for (PalletAllocator.PalletDraft draft : allocation.pallets()) { + SourceOrderSnapshot sample = warehouseSamples.get(draft.warehouseKey()); + InboundPlanPallet pallet = new InboundPlanPallet(); + pallet.setId(IdWorker.getId()); + pallet.setPlanId(plan.getId()); + pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", result.size() + 1)); + pallet.setDestinationWarehouseId(sample.destinationWarehouseId()); + pallet.setDestinationWarehouseCode(sample.destinationWarehouseCode()); + pallet.setDestinationWarehouseName(sample.destinationWarehouseName()); + pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM); + pallet.setAllocatedVolumeCbm(draft.allocatedVolumeCbm()); + pallet.setSequenceNo(result.size() + 1); + pallet.setAssignmentStatus("UNASSIGNED"); + pallet.setActualVolumeCbm(BigDecimal.ZERO); + pallet.setActualPackageCount(0); + palletMapper.insert(pallet); + result.put(draft, pallet); + } + return result; + } + + private void createAllocations(InboundPlan plan, PalletAllocator.Result allocation, + Map lineByOrder, + Map pallets) { + for (PalletAllocator.AllocationDraft draft : allocation.allocations()) { + InboundPlanPalletAllocation item = new InboundPlanPalletAllocation(); + item.setId(IdWorker.getId()); + item.setPlanId(plan.getId()); + item.setLineId(lineByOrder.get(draft.orderId()).getId()); + item.setPalletId(pallets.get(draft.pallet()).getId()); + item.setAllocatedVolumeCbm(draft.allocatedVolumeCbm()); + item.setPlannedPackageCount(draft.plannedPackageCount()); + item.setCountedPackageCount(0); + item.setPickedPackageCount(0); + item.setActualVolumeCbm(BigDecimal.ZERO); + item.setStatus("PENDING_COUNT"); + item.setVersion(0); + allocationMapper.insert(item); + } + } + + private void validateOrders(List orders) { + for (SourceOrderSnapshot order : orders) { + if (!"PENDING_ASSIGNMENT".equals(order.currentStatus())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Inbound code " + order.inboundCode() + " is not ready for an inbound plan"); + } + if (order.destinationCount() != 1 || order.destinationMismatchCount() != 0 || warehouseKey(order) == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Inbound code " + order.inboundCode() + + " must have one destination warehouse consistent with all cargo details"); + } + if (order.inboundOrderNo() != null && !order.inboundOrderNo().isBlank()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Inbound code " + order.inboundCode() + " already belongs to an inbound plan"); + } + if (volume(order).signum() < 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Inbound code " + order.inboundCode() + " has negative volume"); + } + if (order.packageCount() < 0 || (order.packageCount() > 0 && volume(order).signum() == 0)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Inbound code " + order.inboundCode() + " has invalid package count or volume"); + } + } + } + + private InboundPlanResponse detail(String customerId, InboundPlan plan) { + if (!customerId.equals(plan.getCustomerId())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found"); + } + List lines = lineMapper.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getPlanId, plan.getId()).orderByAsc(InboundPlanLine::getId)); + List pallets = palletMapper.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getPlanId, plan.getId()).orderByAsc(InboundPlanPallet::getSequenceNo)); + List allocations = allocationMapper.selectList( + new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getPlanId, plan.getId()) + .orderByAsc(InboundPlanPalletAllocation::getId)); + return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getContainerNo(), + plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), plan.getTotalVolumeCbm(), + lines.stream().map(line -> new InboundPlanResponse.Line(line.getId(), line.getOrderId(), line.getInboundCode(), line.getFbaWarehouseCode(), + line.getDestinationWarehouseId(), line.getDestinationWarehouseCode(), line.getDestinationWarehouseName(), + line.getVolumeCbm(), line.getRequiredPackageCount(), line.getPlannedPalletCount())).toList(), + pallets.stream().map(pallet -> new InboundPlanResponse.Pallet(pallet.getId(), pallet.getPalletNo(), + pallet.getDestinationWarehouseId(), pallet.getDestinationWarehouseCode(), pallet.getDestinationWarehouseName(), + pallet.getCapacityCbm(), pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(), + pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId(), + pallet.getAssignmentStatus(), pallet.getLockedAt(), pallet.getActualVolumeCbm(), + pallet.getActualPackageCount())).toList(), + allocations.stream().map(item -> new InboundPlanResponse.Allocation(item.getLineId(), item.getPalletId(), + item.getAllocatedVolumeCbm(), item.getPlannedPackageCount(), item.getCountedPackageCount(), + item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion())).toList()); + } + + private BigDecimal volume(SourceOrderSnapshot order) { + return Objects.requireNonNullElse(order.volumeCbm(), BigDecimal.ZERO); + } + + private String warehouseKey(SourceOrderSnapshot order) { + String id = trimToNull(order.destinationWarehouseId()); + String code = trimToNull(order.destinationWarehouseCode()); + return id != null ? "ID:" + id : code == null ? null : "CODE:" + code; + } + + private static String trimToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/PalletAllocator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/PalletAllocator.java new file mode 100644 index 000000000..74f6f0af9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/PalletAllocator.java @@ -0,0 +1,129 @@ +package com.baiyue.lastleg.inboundplan.service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class PalletAllocator { + public static final BigDecimal PALLET_CAPACITY_CBM = new BigDecimal("2.0000"); + + private PalletAllocator() { + } + + public static Result allocate(List lines) { + Map> groups = new LinkedHashMap<>(); + for (LineInput line : lines) { + if (line.volumeCbm() == null || line.volumeCbm().signum() < 0) { + throw new IllegalArgumentException("Volume must be zero or positive"); + } + if (line.packageCount() < 0) { + throw new IllegalArgumentException("Package count must be zero or positive"); + } + if (line.volumeCbm().signum() == 0 && line.packageCount() > 0) { + throw new IllegalArgumentException("Positive package count requires positive volume"); + } + groups.computeIfAbsent(line.warehouseKey(), ignored -> new ArrayList<>()).add(line); + } + + List pallets = new ArrayList<>(); + List allocations = new ArrayList<>(); + for (List group : groups.values()) { + PalletDraft current = null; + int sequence = 0; + for (LineInput line : group) { + BigDecimal remaining = line.volumeCbm(); + while (remaining.signum() > 0) { + if (current == null || current.remainingCapacity().signum() == 0) { + current = new PalletDraft(line.warehouseKey(), ++sequence, BigDecimal.ZERO); + pallets.add(current); + } + BigDecimal allocated = remaining.min(current.remainingCapacity()); + current.add(allocated); + allocations.add(new AllocationDraft(line.orderId(), current, allocated, 0)); + remaining = remaining.subtract(allocated); + } + } + } + return new Result(List.copyOf(pallets), distributePackages(lines, allocations)); + } + + private static List distributePackages(List lines, + List allocations) { + Map linesByOrder = new LinkedHashMap<>(); + for (LineInput line : lines) { + if (linesByOrder.put(line.orderId(), line) != null) { + throw new IllegalArgumentException("Duplicate order id " + line.orderId()); + } + } + Map> allocationsByOrder = new LinkedHashMap<>(); + for (AllocationDraft allocation : allocations) { + allocationsByOrder.computeIfAbsent(allocation.orderId(), ignored -> new ArrayList<>()).add(allocation); + } + + List result = new ArrayList<>(allocations.size()); + Map replacements = new LinkedHashMap<>(); + for (LineInput line : lines) { + List orderAllocations = allocationsByOrder.getOrDefault(line.orderId(), List.of()); + int assigned = 0; + for (int index = 0; index < orderAllocations.size(); index++) { + AllocationDraft allocation = orderAllocations.get(index); + int packages = index == orderAllocations.size() - 1 + ? line.packageCount() - assigned + : allocation.allocatedVolumeCbm().multiply(BigDecimal.valueOf(line.packageCount())) + .divide(line.volumeCbm(), 0, RoundingMode.DOWN).intValueExact(); + assigned += packages; + replacements.put(allocation, new AllocationDraft(allocation.orderId(), allocation.pallet(), + allocation.allocatedVolumeCbm(), packages)); + } + } + for (AllocationDraft allocation : allocations) { + result.add(replacements.get(allocation)); + } + return List.copyOf(result); + } + + public record LineInput(Long orderId, String warehouseKey, BigDecimal volumeCbm, int packageCount) { + } + + public static final class PalletDraft { + private final String warehouseKey; + private final int sequenceNo; + private BigDecimal allocatedVolumeCbm; + + private PalletDraft(String warehouseKey, int sequenceNo, BigDecimal allocatedVolumeCbm) { + this.warehouseKey = warehouseKey; + this.sequenceNo = sequenceNo; + this.allocatedVolumeCbm = allocatedVolumeCbm; + } + + private void add(BigDecimal volume) { + allocatedVolumeCbm = allocatedVolumeCbm.add(volume); + } + + public String warehouseKey() { + return warehouseKey; + } + + public int sequenceNo() { + return sequenceNo; + } + + public BigDecimal allocatedVolumeCbm() { + return allocatedVolumeCbm; + } + + public BigDecimal remainingCapacity() { + return PALLET_CAPACITY_CBM.subtract(allocatedVolumeCbm); + } + } + + public record AllocationDraft(Long orderId, PalletDraft pallet, BigDecimal allocatedVolumeCbm, + int plannedPackageCount) { + } + + public record Result(List pallets, List allocations) { + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/InventoryMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/InventoryMapperConfiguration.java new file mode 100644 index 000000000..c3cd7e45f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/InventoryMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inventory; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.inventory.mapper") +public class InventoryMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/InventoryCommandMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/InventoryCommandMapperConfiguration.java new file mode 100644 index 000000000..953a3ceb7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/InventoryCommandMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inventory.command; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.inventory.command.mapper") +public class InventoryCommandMapperConfiguration { +} 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 new file mode 100644 index 000000000..71d44c87b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java @@ -0,0 +1,106 @@ +package com.baiyue.lastleg.inventory.command.controller; + +import com.baiyue.common.core.api.ApiResponse; +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 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.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@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"; + @Resource private InventoryCommandService service; + + @PostMapping("/hold") + @Operation(summary = "Move available inbound-order packages to hold") + public ApiResponse hold(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody InventoryCommandRequests.Move request) { + requirePermission(user, MANAGE_PERMISSION); + return ApiResponse.success(service.hold(scope(user, request.customerId()), operator(user), request)); + } + + @PostMapping("/release") + @Operation(summary = "Release held inbound-order packages") + 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") + public ApiResponse adjust(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody InventoryCommandRequests.Adjustment request) { + requirePermission(user, ADJUST_PERMISSION); + return ApiResponse.success(service.adjust(scope(user, request.customerId()), operator(user), request)); + } + + private String scope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Platform request must specify target tenant"); + } + return requestedCustomer.trim(); + } + return String.valueOf(user.tenantId()); + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } + + private void requirePermission(AuthenticatedUser user, String permission) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + 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/inventory/command/dto/InventoryCommandRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java new file mode 100644 index 000000000..e768ccb21 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.inventory.command.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; + +public final class InventoryCommandRequests { + private InventoryCommandRequests() { + } + + public record StockIn( + String customerId, + String customerName, + @NotBlank String overseasWarehouseId, + String overseasWarehouseCode, + String overseasWarehouseName, + String destinationWarehouseId, + @NotBlank String destinationWarehouseCode, + String destinationWarehouseName, + @NotNull Long inboundOrderId, + @NotBlank String inboundOrderNo, + @NotBlank String inboundCode, + @NotNull Long palletId, + @NotBlank String palletNo, + String batchNo, + Long warehouseLocationId, + String warehouseLocationCode, + @Min(0) long packages, + String businessOrderNo, + String sourceEventId, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark + ) { + } + + public record Move( + String customerId, + @NotNull Long positionId, + @Positive long packages, + String businessOrderNo, + String outboundOrderNo, + String sourceEventId, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark + ) { + } + + public record Adjustment( + String customerId, + @NotNull Long positionId, + long packageDelta, + @NotBlank String reason, + String businessOrderNo, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark + ) { + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandResponse.java new file mode 100644 index 000000000..944c38358 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandResponse.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.inventory.command.dto; + +public record InventoryCommandResponse( + Long operationId, + String operationType, + Long balanceId, + Long positionId, + long availablePackages, + long holdPackages, + long allocatedPackages, + long pickedPackages, + boolean palletEmpty, + boolean replayed +) { + public InventoryCommandResponse asReplay() { + return new InventoryCommandResponse(operationId, operationType, balanceId, positionId, + availablePackages, holdPackages, allocatedPackages, pickedPackages, palletEmpty, true); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java new file mode 100644 index 000000000..97b33ae24 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.inventory.command.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface InventoryCommandMapper { + @Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND id=#{positionId} LIMIT 1 FOR UPDATE") + InventoryPosition lockPosition(@Param("customerId") String customerId, + @Param("positionId") Long positionId); + + @Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} AND id=#{balanceId} LIMIT 1 FOR UPDATE") + InventoryBalance lockBalance(@Param("customerId") String customerId, + @Param("balanceId") Long balanceId); + + @Select("SELECT COALESCE(SUM(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages),0) " + + "FROM ll_inventory_position WHERE customer_id=#{customerId} AND pallet_id=#{palletId}") + long palletPackages(@Param("customerId") String customerId, @Param("palletId") Long palletId); +} 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 new file mode 100644 index 000000000..e96f09a59 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -0,0 +1,453 @@ +package com.baiyue.lastleg.inventory.command.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryOperation; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryOperationMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.HexFormat; + +@Service +public class InventoryCommandService { + @Resource private InventoryBalanceMapper balances; + @Resource private InventoryPositionMapper positions; + @Resource private InventoryLedgerMapper ledgers; + @Resource private InventoryOperationMapper operations; + @Resource private InventoryCommandMapper commands; + @Resource private WarehouseResourceGateway warehouseGateway; + @Resource private ObjectMapper objectMapper; + + @Transactional + public InventoryCommandResponse stockIn(String customerId, String operatorId, + InventoryCommandRequests.StockIn request) { + return execute(customerId, operatorId, "STOCK_IN", request.idempotencyKey(), request, operation -> { + InventoryBalance balance = balance(customerId, request); + InventoryPosition position = position(customerId, balance, request); + long before = value(position.getAvailablePackages()); + position.setAvailablePackages(before + request.packages()); + position.setStatus("OCCUPIED"); + position.setVersion(value(position.getVersion()) + 1); + position.setUpdatedAt(LocalDateTime.now()); + positions.updateById(position); + + balance.setAvailablePackages(value(balance.getAvailablePackages()) + request.packages()); + touch(balance); + balances.updateById(balance); + ledger(customerId, operatorId, operation.getId(), balance, position, "STOCK_IN", + "OUTSIDE", "AVAILABLE", before, request.packages(), before + request.packages(), + request.businessOrderNo(), request.inboundOrderNo(), null, request.sourceEventId(), + request.idempotencyKey(), request.remark()); + return response(operation, balance, position, false); + }); + } + + @Transactional + public InventoryCommandResponse hold(String customerId, String operatorId, InventoryCommandRequests.Move request) { + return move(customerId, operatorId, "HOLD", Bucket.AVAILABLE, Bucket.HOLD, request); + } + + @Transactional + public InventoryCommandResponse release(String customerId, String operatorId, InventoryCommandRequests.Move request) { + return move(customerId, operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request); + } + + @Transactional + public InventoryCommandResponse allocate(String customerId, String operatorId, InventoryCommandRequests.Move request) { + return move(customerId, operatorId, "ALLOCATE", Bucket.AVAILABLE, Bucket.ALLOCATED, request); + } + + @Transactional + public InventoryCommandResponse unallocate(String customerId, String operatorId, InventoryCommandRequests.Move request) { + return move(customerId, operatorId, "UNALLOCATE", Bucket.ALLOCATED, Bucket.AVAILABLE, request); + } + + @Transactional + public InventoryCommandResponse pick(String customerId, String operatorId, InventoryCommandRequests.Move request) { + return move(customerId, operatorId, "PICK", Bucket.ALLOCATED, Bucket.PICKED, request); + } + + @Transactional + public InventoryCommandResponse ship(String customerId, String operatorId, InventoryCommandRequests.Move request) { + return execute(customerId, operatorId, "SHIP", request.idempotencyKey(), request, operation -> { + Locked locked = locked(customerId, request.positionId()); + InventoryPosition position = locked.position(); + InventoryBalance balance = locked.balance(); + requireAtLeast(value(position.getPickedPackages()), request.packages(), "picked"); + requireAtLeast(value(balance.getPickedPackages()), request.packages(), "balance picked"); + long before = value(position.getPickedPackages()); + position.setPickedPackages(before - request.packages()); + position.setStatus(positionPackages(position) == 0 ? "EMPTY" : "OCCUPIED"); + position.setVersion(value(position.getVersion()) + 1); + position.setUpdatedAt(LocalDateTime.now()); + positions.updateById(position); + + balance.setPickedPackages(value(balance.getPickedPackages()) - request.packages()); + touch(balance); + balances.updateById(balance); + boolean palletEmpty = commands.palletPackages(customerId, position.getPalletId()) == 0; + if (palletEmpty) warehouseGateway.scrapPallet(customerId, position.getPalletId()); + ledger(customerId, operatorId, operation.getId(), balance, position, "SHIP", + "PICKED", "SHIPPED", before, -request.packages(), before - request.packages(), + request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(), + request.idempotencyKey(), request.remark()); + return response(operation, balance, position, palletEmpty); + }); + } + + @Transactional + public InventoryCommandResponse adjust(String customerId, String operatorId, + InventoryCommandRequests.Adjustment request) { + if (request.packageDelta() == 0) { + throw badRequest("Adjustment package delta cannot be zero"); + } + return execute(customerId, operatorId, "ADJUST", request.idempotencyKey(), request, operation -> { + Locked locked = locked(customerId, request.positionId()); + InventoryPosition position = locked.position(); + InventoryBalance balance = locked.balance(); + long before = value(position.getAvailablePackages()); + long after = before + request.packageDelta(); + long balanceAfter = value(balance.getAvailablePackages()) + request.packageDelta(); + if (after < 0 || balanceAfter < 0) { + throw conflict("Adjustment would make inventory negative"); + } + position.setAvailablePackages(after); + position.setStatus(positionPackages(position) == 0 ? "EMPTY" : "OCCUPIED"); + position.setVersion(value(position.getVersion()) + 1); + position.setUpdatedAt(LocalDateTime.now()); + positions.updateById(position); + balance.setAvailablePackages(balanceAfter); + touch(balance); + balances.updateById(balance); + ledger(customerId, operatorId, operation.getId(), balance, position, "ADJUST", + "AVAILABLE", "AVAILABLE", before, request.packageDelta(), after, + request.businessOrderNo(), null, null, null, request.idempotencyKey(), + request.reason() + remarkSuffix(request.remark())); + return response(operation, balance, position, false); + }); + } + + private InventoryCommandResponse move(String customerId, String operatorId, String operationType, + Bucket from, Bucket to, InventoryCommandRequests.Move request) { + return execute(customerId, operatorId, operationType, request.idempotencyKey(), request, operation -> { + Locked locked = locked(customerId, request.positionId()); + InventoryPosition position = locked.position(); + InventoryBalance balance = locked.balance(); + requireAtLeast(packages(position, from), request.packages(), from.name().toLowerCase()); + requireAtLeast(packages(balance, from), request.packages(), "balance " + from.name().toLowerCase()); + long beforeTarget = packages(position, to); + packages(position, from, packages(position, from) - request.packages()); + packages(position, to, beforeTarget + request.packages()); + position.setVersion(value(position.getVersion()) + 1); + position.setUpdatedAt(LocalDateTime.now()); + positions.updateById(position); + + packages(balance, from, packages(balance, from) - request.packages()); + packages(balance, to, packages(balance, to) + request.packages()); + touch(balance); + balances.updateById(balance); + ledger(customerId, operatorId, operation.getId(), balance, position, operationType, + from.name(), to.name(), beforeTarget, request.packages(), beforeTarget + request.packages(), + request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(), + request.idempotencyKey(), request.remark()); + return response(operation, balance, position, false); + }); + } + + private InventoryBalance balance(String customerId, InventoryCommandRequests.StockIn request) { + InventoryBalance balance = balances.lockDimension(customerId, request.overseasWarehouseId(), + request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode()); + if (balance != null) return balance; + balance = new InventoryBalance(); + balance.setId(IdWorker.getId()); + balance.setCustomerId(customerId); + balance.setCustomerName(trim(request.customerName())); + balance.setOverseasWarehouseId(request.overseasWarehouseId().trim()); + balance.setOverseasWarehouseCode(trim(request.overseasWarehouseCode())); + balance.setOverseasWarehouseName(trim(request.overseasWarehouseName())); + balance.setDestinationWarehouseId(trim(request.destinationWarehouseId())); + balance.setDestinationWarehouseCode(request.destinationWarehouseCode().trim()); + balance.setDestinationWarehouseName(trim(request.destinationWarehouseName())); + balance.setInboundOrderId(request.inboundOrderId()); + balance.setInboundOrderNo(request.inboundOrderNo().trim()); + balance.setInboundCode(request.inboundCode().trim()); + zero(balance); + balance.setVersion(0L); + balance.setCreatedAt(LocalDateTime.now()); + balance.setUpdatedAt(balance.getCreatedAt()); + try { + balances.insert(balance); + return balance; + } catch (DuplicateKeyException exception) { + InventoryBalance concurrent = balances.lockDimension(customerId, request.overseasWarehouseId(), + request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode()); + if (concurrent != null) return concurrent; + throw exception; + } + } + + private InventoryPosition position(String customerId, InventoryBalance balance, + InventoryCommandRequests.StockIn request) { + String batchNo = trim(request.batchNo()) == null ? "DEFAULT" : request.batchNo().trim(); + InventoryPosition position = positions.lockPosition(customerId, balance.getId(), request.palletId(), batchNo); + if (position != null) return position; + position = new InventoryPosition(); + position.setId(IdWorker.getId()); + position.setCustomerId(customerId); + position.setBalanceId(balance.getId()); + position.setOverseasWarehouseId(balance.getOverseasWarehouseId()); + position.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); + position.setPalletId(request.palletId()); + position.setPalletNo(request.palletNo().trim()); + position.setBatchNo(batchNo); + position.setWarehouseLocationId(request.warehouseLocationId()); + String locationCode = trim(request.warehouseLocationCode()); + position.setWarehouseLocationCode(locationCode == null + ? "LOC-" + defaultCode(balance) + "-DEFAULT" : locationCode); + position.setAvailablePackages(0L); + position.setHoldPackages(0L); + position.setAllocatedPackages(0L); + position.setPickedPackages(0L); + position.setReceivingPackages(0L); + position.setStatus("OCCUPIED"); + position.setVersion(0L); + position.setCreatedAt(LocalDateTime.now()); + position.setUpdatedAt(position.getCreatedAt()); + try { + positions.insert(position); + return position; + } catch (DuplicateKeyException exception) { + InventoryPosition concurrent = positions.lockPosition(customerId, balance.getId(), request.palletId(), batchNo); + if (concurrent != null) return concurrent; + throw exception; + } + } + + private Locked locked(String customerId, Long positionId) { + InventoryPosition position = commands.lockPosition(customerId, positionId); + if (position == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory position not found"); + InventoryBalance balance = commands.lockBalance(customerId, position.getBalanceId()); + if (balance == null) throw new IllegalStateException("Inventory position balance is missing"); + return new Locked(balance, position); + } + + private InventoryCommandResponse execute(String customerId, String operatorId, String operationType, + String key, Object request, Work work) { + String normalizedKey = required(key, "Idempotency key is required"); + String requestHash = hash(request); + InventoryOperation existing = operations.findIdempotency(customerId, normalizedKey); + if (existing != null) return replay(existing, operationType, requestHash); + + InventoryOperation operation = new InventoryOperation(); + operation.setId(IdWorker.getId()); + operation.setCustomerId(customerId); + operation.setIdempotencyKey(normalizedKey); + operation.setOperationType(operationType); + operation.setRequestHash(requestHash); + operation.setStatus("PROCESSING"); + operation.setCreatedAt(LocalDateTime.now()); + operation.setUpdatedAt(operation.getCreatedAt()); + try { + operations.insert(operation); + } catch (DuplicateKeyException exception) { + InventoryOperation concurrent = operations.findIdempotency(customerId, normalizedKey); + if (concurrent != null) return replay(concurrent, operationType, requestHash); + throw exception; + } + + InventoryCommandResponse response = work.run(operation); + operation.setStatus("COMPLETED"); + operation.setResultJson(write(response)); + operation.setUpdatedAt(LocalDateTime.now()); + operations.updateById(operation); + return response; + } + + private InventoryCommandResponse replay(InventoryOperation operation, String type, String requestHash) { + if (!type.equals(operation.getOperationType()) || !requestHash.equals(operation.getRequestHash())) { + throw conflict("Idempotency key was already used with a different inventory request"); + } + if (!"COMPLETED".equals(operation.getStatus()) || trim(operation.getResultJson()) == null) { + throw conflict("Inventory operation is still processing"); + } + try { + return objectMapper.readValue(operation.getResultJson(), InventoryCommandResponse.class).asReplay(); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot read inventory idempotency result", exception); + } + } + + private void ledger(String customerId, String operatorId, Long operationId, InventoryBalance balance, + InventoryPosition position, String businessType, String from, String to, + long beforePackages, long deltaPackages, long afterPackages, + String businessOrderNo, String inboundOrderNo, String outboundOrderNo, + String sourceEventId, String idempotencyKey, String remark) { + InventoryLedger ledger = new InventoryLedger(); + ledger.setId(IdWorker.getId()); + ledger.setCustomerId(customerId); + ledger.setBalanceId(balance.getId()); + ledger.setPositionId(position.getId()); + ledger.setOverseasWarehouseId(balance.getOverseasWarehouseId()); + ledger.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); + ledger.setInboundOrderId(balance.getInboundOrderId()); + ledger.setPalletId(position.getPalletId()); + ledger.setPalletNo(position.getPalletNo()); + ledger.setBatchNo(position.getBatchNo()); + ledger.setWarehouseLocationCode(position.getWarehouseLocationCode()); + ledger.setBusinessType(businessType); + ledger.setBusinessOrderId(balance.getInboundOrderId()); + ledger.setBusinessOrderNo(trim(businessOrderNo)); + ledger.setInboundCode(balance.getInboundCode()); + ledger.setInboundOrderNo(trim(inboundOrderNo) == null + ? balance.getInboundOrderNo() : trim(inboundOrderNo)); + ledger.setOutboundOrderNo(trim(outboundOrderNo)); + ledger.setSourceEventId(trim(sourceEventId)); + ledger.setIdempotencyKey(idempotencyKey + ":" + operationId); + ledger.setStatusFrom(from); + ledger.setStatusTo(to); + ledger.setBeforePackages(beforePackages); + ledger.setDeltaPackages(deltaPackages); + ledger.setAfterPackages(afterPackages); + ledger.setBeforeQuantity(0L); + ledger.setDeltaQuantity(0L); + ledger.setAfterQuantity(0L); + ledger.setOperatorId(trim(operatorId)); + ledger.setRemark(trim(remark)); + ledger.setOccurredAt(LocalDateTime.now()); + ledger.setCreatedAt(ledger.getOccurredAt()); + ledgers.insert(ledger); + } + + private InventoryCommandResponse response(InventoryOperation operation, InventoryBalance balance, + InventoryPosition position, boolean palletEmpty) { + return new InventoryCommandResponse(operation.getId(), operation.getOperationType(), balance.getId(), + position.getId(), value(balance.getAvailablePackages()), value(balance.getHoldPackages()), + value(balance.getAllocatedPackages()), value(balance.getPickedPackages()), palletEmpty, false); + } + + private static void touch(InventoryBalance balance) { + balance.setVersion(value(balance.getVersion()) + 1); + balance.setUpdatedAt(LocalDateTime.now()); + } + + private static void zero(InventoryBalance balance) { + balance.setAvailablePackages(0L); balance.setHoldPackages(0L); balance.setAllocatedPackages(0L); + balance.setPickedPackages(0L); balance.setInTransitPackages(0L); balance.setReceivingPackages(0L); + balance.setProcurementPackages(0L); + } + + private static long positionPackages(InventoryPosition position) { + return value(position.getAvailablePackages()) + value(position.getHoldPackages()) + + value(position.getAllocatedPackages()) + value(position.getPickedPackages()) + + value(position.getReceivingPackages()); + } + + private static long packages(InventoryPosition position, Bucket bucket) { + return switch (bucket) { + case AVAILABLE -> value(position.getAvailablePackages()); + case HOLD -> value(position.getHoldPackages()); + case ALLOCATED -> value(position.getAllocatedPackages()); + case PICKED -> value(position.getPickedPackages()); + }; + } + + private static void packages(InventoryPosition position, Bucket bucket, long value) { + switch (bucket) { + case AVAILABLE -> position.setAvailablePackages(value); + case HOLD -> position.setHoldPackages(value); + case ALLOCATED -> position.setAllocatedPackages(value); + case PICKED -> position.setPickedPackages(value); + } + } + + private static long packages(InventoryBalance balance, Bucket bucket) { + return switch (bucket) { + case AVAILABLE -> value(balance.getAvailablePackages()); + case HOLD -> value(balance.getHoldPackages()); + case ALLOCATED -> value(balance.getAllocatedPackages()); + case PICKED -> value(balance.getPickedPackages()); + }; + } + + private static void packages(InventoryBalance balance, Bucket bucket, long value) { + switch (bucket) { + case AVAILABLE -> balance.setAvailablePackages(value); + case HOLD -> balance.setHoldPackages(value); + case ALLOCATED -> balance.setAllocatedPackages(value); + case PICKED -> balance.setPickedPackages(value); + } + } + + private String hash(Object value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(write(value).getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private String write(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot serialize inventory operation", exception); + } + } + + private static String defaultCode(InventoryBalance balance) { + String code = trim(balance.getOverseasWarehouseCode()); + return code == null ? balance.getOverseasWarehouseId() : code; + } + + private static void requireAtLeast(long actual, long requested, String field) { + if (actual < requested) throw conflict("Insufficient " + field + " packages"); + } + + private static String required(String value, String message) { + String normalized = trim(value); + if (normalized == null) throw badRequest(message); + return normalized; + } + + private static String trim(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private static String remarkSuffix(String remark) { + String value = trim(remark); + return value == null ? "" : "; " + value; + } + + private static long value(Long value) { return value == null ? 0L : value; } + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } + private static ResponseStatusException badRequest(String message) { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + + private enum Bucket { AVAILABLE, HOLD, ALLOCATED, PICKED } + private record Locked(InventoryBalance balance, InventoryPosition position) {} + @FunctionalInterface private interface Work { InventoryCommandResponse run(InventoryOperation operation); } +} 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 new file mode 100644 index 000000000..c7e62a204 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryBalance.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.inventory.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** Real-time package balance for one inbound order at one overseas warehouse. */ +@Data +@TableName("ll_inventory_balance") +public class InventoryBalance { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private String customerName; + private String overseasWarehouseId; + private String overseasWarehouseCode; + private String overseasWarehouseName; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private Long inboundOrderId; + private String inboundOrderNo; + private String inboundCode; + private Long availablePackages; + private Long holdPackages; + private Long allocatedPackages; + private Long pickedPackages; + private Long inTransitPackages; + private Long receivingPackages; + private Long procurementPackages; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java new file mode 100644 index 000000000..0e9dc4d95 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.inventory.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** Append-only inventory fact. Never update or delete this entity. */ +@Data +@TableName("ll_inventory_ledger") +public class InventoryLedger { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private Long balanceId; + private Long positionId; + private String overseasWarehouseId; + private String destinationWarehouseCode; + private Long inboundOrderId; + private Long palletId; + private String palletNo; + private String batchNo; + private String skuCode; + private String cartonMark; + private Long cartonPackages; + private String warehouseLocationCode; + private String businessType; + private Long businessOrderId; + private String businessOrderNo; + private String inboundCode; + private String inboundOrderNo; + private String outboundOrderNo; + private String sourceEventId; + private String idempotencyKey; + private String statusFrom; + private String statusTo; + private Long beforePackages; + private Long deltaPackages; + private Long afterPackages; + /** Quantity snapshot is retained even though current PDA operations are carton-count based. */ + private Long beforeQuantity; + private Long deltaQuantity; + private Long afterQuantity; + private String operatorId; + private String remark; + private LocalDateTime occurredAt; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryOperation.java new file mode 100644 index 000000000..43317683d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryOperation.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inventory.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** Command-level idempotency record for inventory mutations. */ +@Data +@TableName("ll_inventory_operation") +public class InventoryOperation { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private String idempotencyKey; + private String operationType; + private String requestHash; + private String status; + private String resultJson; + private String lastError; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryPosition.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryPosition.java new file mode 100644 index 000000000..3e5b276ae --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryPosition.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.inventory.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** Physical stock snapshot. A location is currently informational only. */ +@Data +@TableName("ll_inventory_position") +public class InventoryPosition { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private Long balanceId; + private String overseasWarehouseId; + private String destinationWarehouseCode; + private Long palletId; + private String palletNo; + private String batchNo; + private Long warehouseLocationId; + private String warehouseLocationCode; + private Long availablePackages; + private Long holdPackages; + private Long allocatedPackages; + private Long pickedPackages; + private Long receivingPackages; + private String status; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} 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 new file mode 100644 index 000000000..437a9dcab --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java @@ -0,0 +1,52 @@ +package com.baiyue.lastleg.inventory.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.model.InventorySummary; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +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") + InventoryBalance findDimension(@Param("customerId") String customerId, + @Param("overseasWarehouseId") String overseasWarehouseId, + @Param("destinationWarehouseCode") String destinationWarehouseCode, + @Param("inboundOrderId") Long inboundOrderId, + @Param("inboundCode") String inboundCode); + + @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") + InventoryBalance lockDimension(@Param("customerId") String customerId, + @Param("overseasWarehouseId") String overseasWarehouseId, + @Param("destinationWarehouseCode") String destinationWarehouseCode, + @Param("inboundOrderId") Long inboundOrderId, + @Param("inboundCode") String inboundCode); + + @Select("") + List listTenant(@Param("customerId") String customerId, + @Param("overseasWarehouseId") String overseasWarehouseId, + @Param("destinationWarehouseCode") String destinationWarehouseCode, + @Param("inboundCode") String inboundCode, + @Param("offset") long offset, + @Param("limit") int limit); + + @Select("") + InventorySummary summarize(@Param("customerId") String customerId, + @Param("overseasWarehouseId") String overseasWarehouseId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java new file mode 100644 index 000000000..f95b777ba --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.inventory.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.time.LocalDateTime; +import java.util.List; + +public interface InventoryLedgerMapper extends BaseMapper { + @Select("SELECT * FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND idempotency_key=#{idempotencyKey} LIMIT 1") + InventoryLedger findIdempotency(@Param("customerId") String customerId, + @Param("idempotencyKey") String idempotencyKey); + + @Select("") + List listTenant(@Param("customerId") String customerId, + @Param("overseasWarehouseId") String overseasWarehouseId, + @Param("inboundCode") String inboundCode, + @Param("businessType") String businessType, + @Param("businessOrderNo") String businessOrderNo, + @Param("startedAt") LocalDateTime startedAt, + @Param("endedAt") LocalDateTime endedAt, + @Param("offset") long offset, + @Param("limit") int limit); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryOperationMapper.java new file mode 100644 index 000000000..3b6a16229 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryOperationMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.inventory.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryOperation; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +public interface InventoryOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_inventory_operation WHERE customer_id=#{customerId} AND idempotency_key=#{idempotencyKey} LIMIT 1") + InventoryOperation findIdempotency(@Param("customerId") String customerId, + @Param("idempotencyKey") String idempotencyKey); + + @Select("SELECT * FROM ll_inventory_operation WHERE customer_id=#{customerId} AND idempotency_key=#{idempotencyKey} LIMIT 1 FOR UPDATE") + InventoryOperation lockIdempotency(@Param("customerId") String customerId, + @Param("idempotencyKey") String idempotencyKey); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java new file mode 100644 index 000000000..59b438680 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.inventory.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +public interface InventoryPositionMapper extends BaseMapper { + @Select("SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id " + + "WHERE b.customer_id=#{customerId} AND p.balance_id=#{balanceId} ORDER BY p.pallet_no,p.batch_no") + List listBalance(@Param("customerId") String customerId, + @Param("balanceId") Long balanceId); + + @Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND pallet_id=#{palletId} " + + "ORDER BY balance_id,batch_no") + List listPallet(@Param("customerId") String customerId, + @Param("palletId") Long palletId); + + @Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId} " + + "AND pallet_id=#{palletId} AND batch_no=#{batchNo} LIMIT 1 FOR UPDATE") + InventoryPosition lockPosition(@Param("customerId") String customerId, + @Param("balanceId") Long balanceId, + @Param("palletId") Long palletId, + @Param("batchNo") String batchNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/model/InventorySummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/model/InventorySummary.java new file mode 100644 index 000000000..39b7585cb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/model/InventorySummary.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.inventory.model; + +import lombok.Data; + +@Data +public class InventorySummary { + private Long balanceCount; + private Long availablePackages; + private Long holdPackages; + private Long allocatedPackages; + private Long pickedPackages; + private Long inTransitPackages; + private Long receivingPackages; + private Long procurementPackages; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/InventoryQueryMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/InventoryQueryMapperConfiguration.java new file mode 100644 index 000000000..049e9f827 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/InventoryQueryMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inventory.query; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.inventory.query.mapper") +public class InventoryQueryMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java new file mode 100644 index 000000000..f9b9f759d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java @@ -0,0 +1,112 @@ +package com.baiyue.lastleg.inventory.query.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.inventory.query.dto.InventoryBalanceView; +import com.baiyue.lastleg.inventory.query.dto.InventoryDashboardResponse; +import com.baiyue.lastleg.inventory.query.dto.InventoryLedgerView; +import com.baiyue.lastleg.inventory.query.dto.InventoryPositionView; +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import com.baiyue.lastleg.inventory.query.service.InventoryQueryService; +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.HttpHeaders; +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.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +@RestController +@Tag(name = "Inventory queries") +public class InventoryQueryController { + private static final String BASE_PATH = "/api/platform/v1/last-leg/inventory"; + + @Resource private InventoryQueryService service; + + @GetMapping(BASE_PATH + "/summary") + @Operation(summary = "Get inbound-order package inventory totals") + public ApiResponse summary( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid InventoryQueryRequests.BalanceQuery query) { + return ApiResponse.success(service.dashboard(scope(user, customerId), query)); + } + + @GetMapping(BASE_PATH + "/balances") + @Operation(summary = "Page inventory by customer, warehouse and inbound order") + public ApiResponse> balances( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid InventoryQueryRequests.BalanceQuery query) { + return ApiResponse.success(service.balances(scope(user, customerId), query)); + } + + @GetMapping(BASE_PATH + "/balances/{balanceId}/positions") + @Operation(summary = "List pallet and batch positions for one inbound-order balance") + public ApiResponse> positions( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long balanceId, + @Valid InventoryQueryRequests.PositionQuery query) { + return ApiResponse.success(service.positions(scope(user, customerId), balanceId, query)); + } + + @GetMapping(BASE_PATH + "/ledger") + @Operation(summary = "Page append-only inventory movements") + public ApiResponse> ledgers( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid InventoryQueryRequests.LedgerQuery query) { + return ApiResponse.success(service.ledgers(scope(user, customerId), query)); + } + + @GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv") + @Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV") + public ResponseEntity exportBalances( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid InventoryQueryRequests.BalanceQuery query) { + return csv(service.exportBalances(scope(user, customerId), query), "inventory-balances.csv"); + } + + @GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv") + @Operation(summary = "Export filtered inventory movements as UTF-8 CSV") + public ResponseEntity exportLedgers( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid InventoryQueryRequests.LedgerQuery query) { + return csv(service.exportLedgers(scope(user, customerId), query), "inventory-ledger.csv"); + } + + private static ResponseEntity csv(byte[] body, String filename) { + return ResponseEntity.ok() + .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename) + .body(body); + } + + private String scope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Platform queries must specify a customer"); + } + return requestedCustomer; + } + return String.valueOf(user.tenantId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryBalanceView.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryBalanceView.java new file mode 100644 index 000000000..64fd75cb9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryBalanceView.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.inventory.query.dto; + +import java.time.LocalDateTime; + +public record InventoryBalanceView( + Long id, + String customerId, + String customerName, + String overseasWarehouseId, + String overseasWarehouseCode, + String overseasWarehouseName, + String destinationWarehouseId, + String destinationWarehouseCode, + String destinationWarehouseName, + Long inboundOrderId, + String inboundOrderNo, + String inboundCode, + long totalPackages, + long availablePackages, + long holdPackages, + long allocatedPackages, + long pickedPackages, + long inTransitPackages, + long receivingPackages, + long procurementPackages, + LocalDateTime updatedAt +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryDashboardResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryDashboardResponse.java new file mode 100644 index 000000000..d73cd23af --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryDashboardResponse.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.inventory.query.dto; + +public record InventoryDashboardResponse( + long inboundOrderCount, + long palletCount, + long totalPackages, + long availablePackages, + long holdPackages, + long allocatedPackages, + long pickedPackages, + long inTransitPackages, + long receivingPackages, + long procurementPackages +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java new file mode 100644 index 000000000..00b602cbc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.inventory.query.dto; + +import java.time.LocalDateTime; + +public record InventoryLedgerView( + Long id, + String customerId, + String overseasWarehouseId, + String destinationWarehouseCode, + Long inboundOrderId, + String inboundOrderNo, + String inboundCode, + String palletNo, + String batchNo, + String warehouseLocationCode, + String businessType, + String businessOrderNo, + String outboundOrderNo, + String statusFrom, + String statusTo, + long beforePackages, + long deltaPackages, + long afterPackages, + String operatorId, + String remark, + LocalDateTime occurredAt +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPositionView.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPositionView.java new file mode 100644 index 000000000..8f67d79d5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPositionView.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.inventory.query.dto; + +import java.time.LocalDateTime; + +public record InventoryPositionView( + Long id, + Long balanceId, + String customerId, + String overseasWarehouseId, + String destinationWarehouseCode, + Long inboundOrderId, + String inboundOrderNo, + String inboundCode, + Long palletId, + String palletNo, + String batchNo, + Long warehouseLocationId, + String warehouseLocationCode, + long totalPackages, + long availablePackages, + long holdPackages, + long allocatedPackages, + long pickedPackages, + long receivingPackages, + String status, + LocalDateTime updatedAt +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java new file mode 100644 index 000000000..0783e3aef --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.inventory.query.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import java.time.LocalDateTime; + +public final class InventoryQueryRequests { + private InventoryQueryRequests() { + } + + public record BalanceQuery( + @Min(1) long page, + @Min(1) @Max(200) long size, + String customerName, + String overseasWarehouseId, + String overseasWarehouseCode, + String destinationWarehouseCode, + String inboundOrderNo, + String inboundCode, + String businessOrderNo, + String inventoryStatus + ) { + public BalanceQuery { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } + + public record PositionQuery(String palletNo, String batchNo, String inventoryStatus) { + } + + public record LedgerQuery( + @Min(1) long page, + @Min(1) @Max(200) long size, + String overseasWarehouseId, + String destinationWarehouseCode, + String businessType, + String businessOrderNo, + String inboundOrderNo, + String inboundCode, + String outboundOrderNo, + String palletNo, + String batchNo, + LocalDateTime occurredFrom, + LocalDateTime occurredTo + ) { + public LedgerQuery { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java new file mode 100644 index 000000000..ab7344b37 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java @@ -0,0 +1,119 @@ +package com.baiyue.lastleg.inventory.query.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import com.baiyue.lastleg.inventory.query.model.InventoryAggregateProjection; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +public interface InventoryQueryMapper { + String BALANCE_FILTER = " WHERE customer_id=#{customerId}" + + " AND customer_name LIKE CONCAT('%',#{q.customerName},'%')" + + " AND overseas_warehouse_id=#{q.overseasWarehouseId}" + + " AND overseas_warehouse_code=#{q.overseasWarehouseCode}" + + " AND destination_warehouse_code=#{q.destinationWarehouseCode}" + + " AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')" + + " AND inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + + " AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=ll_inventory_balance.id AND l.customer_id=#{customerId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))" + + "" + + " AND available_packages > 0" + + " AND hold_packages > 0" + + " AND allocated_packages > 0" + + " AND picked_packages > 0" + + " AND in_transit_packages > 0" + + " AND receiving_packages > 0" + + " AND procurement_packages > 0" + + ""; + + String JOINED_BALANCE_FILTER = " WHERE b.customer_id=#{customerId}" + + " AND b.customer_name LIKE CONCAT('%',#{q.customerName},'%')" + + " AND b.overseas_warehouse_id=#{q.overseasWarehouseId}" + + " AND b.overseas_warehouse_code=#{q.overseasWarehouseCode}" + + " AND b.destination_warehouse_code=#{q.destinationWarehouseCode}" + + " AND b.inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')" + + " AND b.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + + " AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=b.id AND l.customer_id=#{customerId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))" + + "" + + " AND b.available_packages > 0" + + " AND b.hold_packages > 0" + + " AND b.allocated_packages > 0" + + " AND b.picked_packages > 0" + + " AND b.in_transit_packages > 0" + + " AND b.receiving_packages > 0" + + " AND b.procurement_packages > 0" + + ""; + + @Select("") + List balances(@Param("customerId") String customerId, + @Param("q") InventoryQueryRequests.BalanceQuery query, + @Param("offset") long offset, + @Param("limit") long limit); + + @Select("") + long countBalances(@Param("customerId") String customerId, + @Param("q") InventoryQueryRequests.BalanceQuery query); + + @Select("") + InventoryAggregateProjection aggregate(@Param("customerId") String customerId, + @Param("q") InventoryQueryRequests.BalanceQuery query); + + @Select("") + long countPallets(@Param("customerId") String customerId, + @Param("q") InventoryQueryRequests.BalanceQuery query); + + @Select("") + List positions(@Param("customerId") String customerId, + @Param("balanceId") long balanceId, + @Param("q") InventoryQueryRequests.PositionQuery query); + + @Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} AND id=#{balanceId} LIMIT 1") + InventoryBalance ownedBalance(@Param("customerId") String customerId, @Param("balanceId") long balanceId); + + String LEDGER_FILTER = " WHERE customer_id=#{customerId}" + + " AND overseas_warehouse_id=#{q.overseasWarehouseId}" + + " AND destination_warehouse_code=#{q.destinationWarehouseCode}" + + " AND business_type=#{q.businessType}" + + " AND business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%')" + + " AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')" + + " AND inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + + " AND outbound_order_no LIKE CONCAT('%',#{q.outboundOrderNo},'%')" + + " AND pallet_no LIKE CONCAT('%',#{q.palletNo},'%')" + + " AND batch_no LIKE CONCAT('%',#{q.batchNo},'%')" + + " AND occurred_at >=#{q.occurredFrom}" + + " AND occurred_at <=#{q.occurredTo}"; + + @Select("") + List ledgers(@Param("customerId") String customerId, + @Param("q") InventoryQueryRequests.LedgerQuery query, + @Param("offset") long offset, + @Param("limit") long limit); + + @Select("") + long countLedgers(@Param("customerId") String customerId, + @Param("q") InventoryQueryRequests.LedgerQuery query); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/model/InventoryAggregateProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/model/InventoryAggregateProjection.java new file mode 100644 index 000000000..066d81207 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/model/InventoryAggregateProjection.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.inventory.query.model; + +import lombok.Data; + +@Data +public class InventoryAggregateProjection { + private Long dimensionCount; + private Long availablePackages; + private Long holdPackages; + private Long allocatedPackages; + private Long pickedPackages; + private Long inTransitPackages; + private Long receivingPackages; + private Long procurementPackages; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java new file mode 100644 index 000000000..7b1b68020 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java @@ -0,0 +1,182 @@ +package com.baiyue.lastleg.inventory.query.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.query.dto.InventoryBalanceView; +import com.baiyue.lastleg.inventory.query.dto.InventoryDashboardResponse; +import com.baiyue.lastleg.inventory.query.dto.InventoryLedgerView; +import com.baiyue.lastleg.inventory.query.dto.InventoryPositionView; +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import com.baiyue.lastleg.inventory.query.mapper.InventoryQueryMapper; +import com.baiyue.lastleg.inventory.query.model.InventoryAggregateProjection; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +@Service +public class InventoryQueryService { + private static final int EXPORT_LIMIT = 10_000; + private static final Set BALANCE_STATUSES = Set.of( + "AVAILABLE", "HOLD", "ALLOCATED", "PICKED", "IN_TRANSIT", "RECEIVING", "PROCUREMENT"); + private static final Set POSITION_STATUSES = Set.of( + "AVAILABLE", "HOLD", "ALLOCATED", "PICKED", "RECEIVING"); + + @Resource private InventoryQueryMapper mapper; + + public InventoryDashboardResponse dashboard(String customerId, InventoryQueryRequests.BalanceQuery query) { + validateStatus(query.inventoryStatus(), BALANCE_STATUSES); + InventoryAggregateProjection value = mapper.aggregate(customerId, query); + long availablePackages = number(value.getAvailablePackages()); + long holdPackages = number(value.getHoldPackages()); + long allocatedPackages = number(value.getAllocatedPackages()); + long pickedPackages = number(value.getPickedPackages()); + long inTransitPackages = number(value.getInTransitPackages()); + long receivingPackages = number(value.getReceivingPackages()); + long procurementPackages = number(value.getProcurementPackages()); + return new InventoryDashboardResponse(number(value.getDimensionCount()), mapper.countPallets(customerId, query), + availablePackages + holdPackages + allocatedPackages + pickedPackages + inTransitPackages + + receivingPackages + procurementPackages, + availablePackages, holdPackages, allocatedPackages, pickedPackages, inTransitPackages, + receivingPackages, procurementPackages); + } + + public PageResult balances(String customerId, InventoryQueryRequests.BalanceQuery query) { + validateStatus(query.inventoryStatus(), BALANCE_STATUSES); + long total = mapper.countBalances(customerId, query); + List records = total == 0 ? List.of() : mapper.balances( + customerId, query, offset(query.page(), query.size()), query.size()).stream().map(this::balanceView).toList(); + return page(records, query.page(), query.size(), total); + } + + public List positions(String customerId, long balanceId, + InventoryQueryRequests.PositionQuery query) { + validateStatus(query.inventoryStatus(), POSITION_STATUSES); + InventoryBalance balance = mapper.ownedBalance(customerId, balanceId); + if (balance == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory balance not found"); + } + return mapper.positions(customerId, balanceId, query).stream().map(value -> positionView(value, balance)).toList(); + } + + public PageResult ledgers(String customerId, InventoryQueryRequests.LedgerQuery query) { + validateDateRange(query); + long total = mapper.countLedgers(customerId, query); + List records = total == 0 ? List.of() : mapper.ledgers( + customerId, query, offset(query.page(), query.size()), query.size()).stream().map(this::ledgerView).toList(); + return page(records, query.page(), query.size(), total); + } + + public byte[] exportBalances(String customerId, InventoryQueryRequests.BalanceQuery query) { + validateStatus(query.inventoryStatus(), BALANCE_STATUSES); + List rows = new ArrayList<>(); + rows.add("customerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," + + "destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,totalPackages,availablePackages," + + "holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages"); + mapper.balances(customerId, query, 0, EXPORT_LIMIT).stream().map(this::balanceView).forEach(v -> rows.add( + String.join(",", csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseCode()), csv(v.overseasWarehouseName()), + csv(v.destinationWarehouseCode()), csv(v.destinationWarehouseName()), csv(v.inboundOrderId()), + csv(v.inboundOrderNo()), csv(v.inboundCode()), + csv(v.totalPackages()), csv(v.availablePackages()), csv(v.holdPackages()), csv(v.allocatedPackages()), + csv(v.pickedPackages()), csv(v.inTransitPackages()), csv(v.receivingPackages()), + csv(v.procurementPackages())))); + return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8); + } + + public byte[] exportLedgers(String customerId, InventoryQueryRequests.LedgerQuery query) { + validateDateRange(query); + List rows = new ArrayList<>(); + rows.add("occurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo," + + "inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo," + + "beforePackages,deltaPackages,afterPackages,operatorId,remark"); + mapper.ledgers(customerId, query, 0, EXPORT_LIMIT).stream().map(this::ledgerView).forEach(v -> rows.add( + String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.overseasWarehouseId()), + csv(v.destinationWarehouseCode()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()), + csv(v.businessType()), csv(v.businessOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()), + csv(v.statusFrom()), csv(v.statusTo()), csv(v.beforePackages()), csv(v.deltaPackages()), + csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark())))); + return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8); + } + + private InventoryBalanceView balanceView(InventoryBalance value) { + long availablePackages = number(value.getAvailablePackages()); + long holdPackages = number(value.getHoldPackages()); + long allocatedPackages = number(value.getAllocatedPackages()); + long pickedPackages = number(value.getPickedPackages()); + long inTransitPackages = number(value.getInTransitPackages()); + long receivingPackages = number(value.getReceivingPackages()); + long procurementPackages = number(value.getProcurementPackages()); + return new InventoryBalanceView(value.getId(), value.getCustomerId(), value.getCustomerName(), value.getOverseasWarehouseId(), + value.getOverseasWarehouseCode(), value.getOverseasWarehouseName(), value.getDestinationWarehouseId(), + value.getDestinationWarehouseCode(), value.getDestinationWarehouseName(), value.getInboundOrderId(), + value.getInboundOrderNo(), value.getInboundCode(), availablePackages + holdPackages + allocatedPackages + pickedPackages + + inTransitPackages + receivingPackages + procurementPackages, + availablePackages, holdPackages, allocatedPackages, pickedPackages, inTransitPackages, + receivingPackages, procurementPackages, value.getUpdatedAt()); + } + + private InventoryPositionView positionView(InventoryPosition value, InventoryBalance balance) { + long available = number(value.getAvailablePackages()); + long hold = number(value.getHoldPackages()); + long allocated = number(value.getAllocatedPackages()); + long picked = number(value.getPickedPackages()); + long receiving = number(value.getReceivingPackages()); + return new InventoryPositionView(value.getId(), value.getBalanceId(), value.getCustomerId(), + value.getOverseasWarehouseId(), value.getDestinationWarehouseCode(), balance.getInboundOrderId(), + balance.getInboundOrderNo(), balance.getInboundCode(), + value.getPalletId(), value.getPalletNo(), value.getBatchNo(), value.getWarehouseLocationId(), + value.getWarehouseLocationCode(), available + hold + allocated + picked + receiving, + available, hold, allocated, picked, receiving, value.getStatus(), + value.getUpdatedAt()); + } + + private InventoryLedgerView ledgerView(InventoryLedger value) { + return new InventoryLedgerView(value.getId(), value.getCustomerId(), value.getOverseasWarehouseId(), + value.getDestinationWarehouseCode(), value.getInboundOrderId(), value.getInboundOrderNo(), value.getInboundCode(), + value.getPalletNo(), value.getBatchNo(), + value.getWarehouseLocationCode(), value.getBusinessType(), value.getBusinessOrderNo(), + value.getOutboundOrderNo(), value.getStatusFrom(), value.getStatusTo(), + number(value.getBeforePackages()), number(value.getDeltaPackages()), number(value.getAfterPackages()), + value.getOperatorId(), value.getRemark(), value.getOccurredAt()); + } + + private static void validateStatus(String status, Set accepted) { + if (status != null && !status.isBlank() && !accepted.contains(status)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported inventory status: " + status); + } + } + + private static void validateDateRange(InventoryQueryRequests.LedgerQuery query) { + if (query.occurredFrom() != null && query.occurredTo() != null + && query.occurredFrom().isAfter(query.occurredTo())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "occurredFrom must not be after occurredTo"); + } + } + + private static long offset(long page, long size) { + return Math.multiplyExact(page - 1, size); + } + + private static long number(Long value) { + return value == null ? 0 : value; + } + + private static PageResult page(List records, long page, long size, long total) { + long pages = total == 0 ? 0 : (total + size - 1) / size; + return new PageResult<>(records, page, size, total, pages); + } + + private static String csv(Object value) { + if (value == null) return ""; + String text = String.valueOf(value); + if (!text.isEmpty() && "=+-@".indexOf(text.charAt(0)) >= 0) text = "'" + text; + return '"' + text.replace("\"", "\"\"") + '"'; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventInboxMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventInboxMapper.java new file mode 100644 index 000000000..ec4dc3988 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventInboxMapper.java @@ -0,0 +1,3 @@ +package com.baiyue.lastleg.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.entity.LastlegEventInbox; import org.apache.ibatis.annotations.Mapper; +@Mapper public interface LastlegEventInboxMapper extends BaseMapper {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderDetailMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderDetailMapper.java new file mode 100644 index 000000000..1b5a957a9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderDetailMapper.java @@ -0,0 +1,3 @@ +package com.baiyue.lastleg.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.entity.LastlegOrderDetail; import org.apache.ibatis.annotations.Mapper; +@Mapper public interface LastlegOrderDetailMapper extends BaseMapper {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java new file mode 100644 index 000000000..f45023ef4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface LastlegOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND source_region=#{sourceRegion} " + + "AND source_inbound_id=#{sourceInboundId} LIMIT 1") + LastlegOrder findSource(@Param("customerId") String customerId, + @Param("sourceRegion") String sourceRegion, + @Param("sourceInboundId") String sourceInboundId); + + @Select("SELECT COUNT(*) FROM ll_order WHERE customer_id=#{customerId} AND container_no=#{containerNo}") + long countContainer(@Param("customerId") String customerId, @Param("containerNo") String containerNo); + + @Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND id=#{id} FOR UPDATE") + LastlegOrder lockById(@Param("customerId") String customerId, @Param("id") Long id); + + @Update("UPDATE ll_order SET picked_package_count=picked_package_count+#{packages},updated_at=NOW() " + + "WHERE customer_id=#{customerId} AND id=#{id} " + + "AND package_count-shipped_package_count-picked_package_count >= #{packages}") + int increasePicked(@Param("customerId") String customerId, @Param("id") Long id, + @Param("packages") long packages); + + @Update("UPDATE ll_order SET current_status=CASE WHEN shipped_package_count+#{packages}=package_count " + + "THEN 'COMPLETED' ELSE 'PARTIALLY_SHIPPED' END," + + "picked_package_count=picked_package_count-#{packages}," + + "shipped_package_count=shipped_package_count+#{packages},outbound_order_no=#{outboundNo},updated_at=NOW() " + + "WHERE customer_id=#{customerId} AND id=#{id} AND picked_package_count >= #{packages} " + + "AND shipped_package_count+#{packages} <= package_count") + int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id, + @Param("packages") long packages, @Param("outboundNo") String outboundNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderStatusHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderStatusHistoryMapper.java new file mode 100644 index 000000000..4a52568cb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderStatusHistoryMapper.java @@ -0,0 +1,3 @@ +package com.baiyue.lastleg.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import org.apache.ibatis.annotations.Mapper; +@Mapper public interface LastlegOrderStatusHistoryMapper extends BaseMapper {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/package-info.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/package-info.java new file mode 100644 index 000000000..5a24a2e5e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/package-info.java @@ -0,0 +1,5 @@ +/** + * MyBatis-Plus single-table data access for the last-leg database. + * / 尾程数据库的 MyBatis-Plus 单表访问层。 + */ +package com.baiyue.lastleg.mapper; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/ProcurementMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/ProcurementMapperConfiguration.java new file mode 100644 index 000000000..ed349662b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/ProcurementMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.procurement; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.procurement.mapper") +public class ProcurementMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java new file mode 100644 index 000000000..d8bb670f0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.procurement.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.procurement.dto.OverseasProcurementRequests; +import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses; +import com.baiyue.lastleg.procurement.service.OverseasProcurementService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/merchant/v1/last-leg/overseas-procurements") +public class MerchantOverseasProcurementController { + @Resource private OverseasProcurementService service; + + @PostMapping + public ApiResponse create( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody OverseasProcurementRequests.Create request) { + return ApiResponse.success(service.create(customer(user), operator(user), request)); + } + + @GetMapping + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, @Valid OverseasProcurementRequests.Query query) { + return ApiResponse.success(service.page(customer(user), query)); + } + + @GetMapping("/{orderNo}") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String orderNo) { + return ApiResponse.success(service.detail(customer(user), orderNo)); + } + + private String customer(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required"); + } + return String.valueOf(user.tenantId()); + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java new file mode 100644 index 000000000..2d0e71a37 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java @@ -0,0 +1,96 @@ +package com.baiyue.lastleg.procurement.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.procurement.dto.OverseasProcurementRequests; +import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses; +import com.baiyue.lastleg.procurement.service.OverseasProcurementService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/overseas-procurements") +public class PlatformOverseasProcurementController { + @Resource private OverseasProcurementService service; + + @GetMapping + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid OverseasProcurementRequests.Query query) { + requirePlatform(user); + return ApiResponse.success(service.page(trim(customerId), query)); + } + + @GetMapping("/{orderNo}") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo) { + requirePlatform(user); + return ApiResponse.success(service.detail(requiredCustomer(customerId), orderNo)); + } + + @PostMapping("/{orderNo}/review") + public ApiResponse review( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Review request) { + requirePermission(user, OverseasProcurementService.REVIEW_PERMISSION); + return ApiResponse.success(service.review(requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PatchMapping("/{orderNo}") + public ApiResponse update( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Update request) { + requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION); + return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, request)); + } + + @PostMapping("/{orderNo}/outbounds") + public ApiResponse addOutbound( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.AddOutbound request) { + requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION); + return ApiResponse.success(service.addOutbound(requiredCustomer(customerId), orderNo, operator(user), request)); + } + + private void requirePlatform(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required"); + } + } + + private void requirePermission(AuthenticatedUser user, String permission) { + requirePlatform(user); + if (user.permissions() == null || !user.permissions().contains(permission)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, permission + " permission is required"); + } + } + + private String requiredCustomer(String customerId) { + String value = trim(customerId); + if (value == null || "0".equals(value)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Target customerId is required"); + } + return value; + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } + + private String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java new file mode 100644 index 000000000..a361ed7bb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java @@ -0,0 +1,63 @@ +package com.baiyue.lastleg.procurement.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public final class OverseasProcurementRequests { + private OverseasProcurementRequests() {} + + public record Create( + @Size(max = 255) String customerName, + @NotBlank @Size(max = 128) String purchaseCategory, + @NotBlank @Size(max = 255) String productName, + @Size(max = 500) String productSpecification, + @Size(max = 2000) String productUrl, + @NotNull @Positive Long purchaseQuantity, + @DecimalMin(value = "0.00") BigDecimal budgetUnitPrice, + @Size(min = 3, max = 3) String currency, + @Size(max = 1000) String receivingAddress, + @NotBlank @Size(max = 128) String contactName, + @NotBlank @Size(max = 64) String contactPhone, + @Size(max = 1000) String remark, + @NotBlank @Size(max = 255) String idempotencyKey) {} + + public record Query( + @Min(1) long page, @Min(1) @Max(200) long size, + String orderNo, String customerKeyword, String purchaseCategory, + String productName, String status, String purchaseWarehouseId, + LocalDateTime createdFrom, LocalDateTime createdTo) {} + + public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {} + + public record Update( + @NotNull @Min(0) Long version, + @Size(max = 128) String purchaseCategory, + @Size(max = 255) String productName, + @Size(max = 500) String productSpecification, + @Size(max = 2000) String productUrl, + @Min(0) Long inboundQuantity, + @Size(max = 128) String purchaseWarehouseId, + @Size(max = 255) String purchaseWarehouseName, + @Size(max = 128) String domesticCarrier, + @Size(max = 128) String domesticTrackingNo, + @Size(max = 128) String overseasCarrier, + @Size(max = 128) String overseasTrackingNo, + LocalDateTime estimatedArrivalAt, + @Size(max = 1000) String remark) {} + + public record AddOutbound( + @NotNull @Positive Long quantity, + @NotBlank @Size(max = 128) String outboundNo, + @Size(max = 128) String carrier, + @Size(max = 128) String trackingNo, + LocalDateTime outboundAt, + @Size(max = 1000) String remark) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java new file mode 100644 index 000000000..a3d3dd5f1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.procurement.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public final class OverseasProcurementResponses { + private OverseasProcurementResponses() {} + + public record Summary( + Long id, String orderNo, String customerId, String customerName, + String purchaseCategory, String productName, String productSpecification, + long purchaseQuantity, BigDecimal budgetUnitPrice, String currency, + String purchaseWarehouseId, String purchaseWarehouseName, + long inboundQuantity, long outboundQuantity, long inventoryQuantity, + String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {} + + public record Outbound( + Long id, long quantity, String outboundNo, String carrier, String trackingNo, + LocalDateTime outboundAt, String remark, String createdBy, LocalDateTime createdAt) {} + + public record History( + String fromStatus, String toStatus, String operatorId, String remark, + LocalDateTime createdAt) {} + + public record Detail( + Summary summary, String productUrl, String receivingAddress, + String contactName, String contactPhone, + String domesticCarrier, String domesticTrackingNo, + String overseasCarrier, String overseasTrackingNo, + LocalDateTime estimatedArrivalAt, String remark, + String createdBy, String reviewedBy, String reviewRemark, LocalDateTime reviewedAt, + List outbounds, List histories) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java new file mode 100644 index 000000000..49c3b189e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java @@ -0,0 +1,48 @@ +package com.baiyue.lastleg.procurement.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_overseas_procurement_order") +public class OverseasProcurementOrder { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private String customerName; + private String orderNo; + private String purchaseCategory; + private String productName; + private String productSpecification; + private String productUrl; + private Long purchaseQuantity; + private BigDecimal budgetUnitPrice; + private String currency; + private String receivingAddress; + private String contactName; + private String contactPhone; + private String purchaseWarehouseId; + private String purchaseWarehouseName; + private Long inboundQuantity; + private Long outboundQuantity; + private String domesticCarrier; + private String domesticTrackingNo; + private String overseasCarrier; + private String overseasTrackingNo; + private LocalDateTime estimatedArrivalAt; + private String status; + private String remark; + private String idempotencyKey; + private String requestHash; + private String createdBy; + private String reviewedBy; + private String reviewRemark; + private LocalDateTime reviewedAt; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOutbound.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOutbound.java new file mode 100644 index 000000000..696c6d40f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOutbound.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.procurement.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_overseas_procurement_outbound") +public class OverseasProcurementOutbound { + @TableId(type = IdType.INPUT) private Long id; + private Long orderId; + private String customerId; + private Long quantity; + private String outboundNo; + private String carrier; + private String trackingNo; + private LocalDateTime outboundAt; + private String remark; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementStatusHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementStatusHistory.java new file mode 100644 index 000000000..68776e3d3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementStatusHistory.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.procurement.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_overseas_procurement_status_history") +public class OverseasProcurementStatusHistory { + @TableId(type = IdType.INPUT) private Long id; + private Long orderId; + private String customerId; + private String fromStatus; + private String toStatus; + private String operatorId; + private String remark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOrderMapper.java new file mode 100644 index 000000000..e22af6a33 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOrderMapper.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.procurement.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface OverseasProcurementOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_overseas_procurement_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1") + OverseasProcurementOrder findNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo); + + @Select("SELECT * FROM ll_overseas_procurement_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + OverseasProcurementOrder findKey(@Param("customerId") String customerId, @Param("key") String key); + + @Select("SELECT * FROM ll_overseas_procurement_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1 FOR UPDATE") + OverseasProcurementOrder lockNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo); + + @Update("UPDATE ll_overseas_procurement_order SET outbound_quantity=outbound_quantity+#{quantity},version=version+1," + + "updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND customer_id=#{customerId} " + + "AND inbound_quantity-outbound_quantity>=#{quantity}") + int addOutbound(@Param("id") Long id, @Param("customerId") String customerId, @Param("quantity") long quantity); +} 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 new file mode 100644 index 000000000..3f96d9478 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOutboundMapper.java @@ -0,0 +1,16 @@ +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/mapper/OverseasProcurementStatusHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementStatusHistoryMapper.java new file mode 100644 index 000000000..b35f731e1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementStatusHistoryMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.procurement.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory; +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 OverseasProcurementStatusHistoryMapper extends BaseMapper { + @Select("SELECT * FROM ll_overseas_procurement_status_history WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY created_at,id") + 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 new file mode 100644 index 000000000..d955c88d8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -0,0 +1,293 @@ +package com.baiyue.lastleg.procurement.service; + +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.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.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HexFormat; +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 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 ObjectMapper objectMapper; + + @Transactional + public OverseasProcurementResponses.Detail create(String customerId, String operatorId, + OverseasProcurementRequests.Create request) { + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + OverseasProcurementOrder replay = orders.findKey(customerId, key); + if (replay != null) return replay(customerId, replay, requestHash); + + LocalDateTime now = LocalDateTime.now(); + OverseasProcurementOrder order = new OverseasProcurementOrder(); + order.setId(IdWorker.getId()); + order.setCustomerId(customerId); + order.setCustomerName(trim(request.customerName())); + order.setOrderNo("HC" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + order.getId()); + order.setPurchaseCategory(request.purchaseCategory().trim()); + order.setProductName(request.productName().trim()); + order.setProductSpecification(trim(request.productSpecification())); + order.setProductUrl(trim(request.productUrl())); + order.setPurchaseQuantity(request.purchaseQuantity()); + order.setBudgetUnitPrice(request.budgetUnitPrice()); + order.setCurrency(defaultCurrency(request.currency())); + order.setReceivingAddress(trim(request.receivingAddress())); + 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); + order.setRequestHash(requestHash); + order.setCreatedBy(operatorId); + order.setVersion(0L); + order.setCreatedAt(now); + order.setUpdatedAt(now); + try { + orders.insert(order); + } catch (DuplicateKeyException exception) { + OverseasProcurementOrder concurrent = orders.findKey(customerId, key); + if (concurrent != null) return replay(customerId, concurrent, requestHash); + throw exception; + } + history(order, null, PENDING_REVIEW, operatorId, request.remark(), now); + return detail(customerId, order.getOrderNo()); + } + + public PageResult page( + String customerId, OverseasProcurementRequests.Query query) { + validateQuery(query); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), OverseasProcurementOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.orderNo()), OverseasProcurementOrder::getOrderNo, query.orderNo()) + .and(StringUtils.hasText(query.customerKeyword()), value -> value + .like(OverseasProcurementOrder::getCustomerId, query.customerKeyword()) + .or().like(OverseasProcurementOrder::getCustomerName, query.customerKeyword())) + .like(StringUtils.hasText(query.purchaseCategory()), OverseasProcurementOrder::getPurchaseCategory, + query.purchaseCategory()) + .like(StringUtils.hasText(query.productName()), OverseasProcurementOrder::getProductName, + query.productName()) + .eq(StringUtils.hasText(query.status()), OverseasProcurementOrder::getStatus, + StringUtils.hasText(query.status()) ? query.status().trim().toUpperCase() : null) + .eq(StringUtils.hasText(query.purchaseWarehouseId()), OverseasProcurementOrder::getPurchaseWarehouseId, + query.purchaseWarehouseId()) + .ge(query.createdFrom() != null, OverseasProcurementOrder::getCreatedAt, query.createdFrom()) + .le(query.createdTo() != null, OverseasProcurementOrder::getCreatedAt, query.createdTo()) + .orderByDesc(OverseasProcurementOrder::getCreatedAt) + .orderByDesc(OverseasProcurementOrder::getId); + Page result = orders.selectPage(Page.of(query.page(), query.size()), wrapper); + return new PageResult<>(result.getRecords().stream().map(this::summary).toList(), query.page(), query.size(), + result.getTotal(), result.getPages()); + } + + public OverseasProcurementResponses.Detail detail(String customerId, String orderNo) { + OverseasProcurementOrder order = orders.findNo(customerId, orderNo); + if (order == null) throw notFound("Overseas procurement order not found"); + return new OverseasProcurementResponses.Detail(summary(order), order.getProductUrl(), + order.getReceivingAddress(), order.getContactName(), order.getContactPhone(), + 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()); + } + + @Transactional + public OverseasProcurementResponses.Detail review(String customerId, String orderNo, String operatorId, + OverseasProcurementRequests.Review request) { + String next = switch (request.decision().trim().toUpperCase()) { + case "APPROVE", APPROVED -> APPROVED; + case "REJECT", REJECTED -> REJECTED; + default -> throw badRequest("Review decision must be APPROVE or REJECT"); + }; + OverseasProcurementOrder order = lock(customerId, orderNo); + if (next.equals(order.getStatus())) return detail(customerId, orderNo); + if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Overseas procurement order was already reviewed"); + LocalDateTime now = LocalDateTime.now(); + order.setStatus(next); + order.setReviewedBy(operatorId); + order.setReviewRemark(trim(request.remark())); + order.setReviewedAt(now); + touch(order, now); + orders.updateById(order); + history(order, PENDING_REVIEW, next, operatorId, request.remark(), now); + return detail(customerId, orderNo); + } + + @Transactional + public OverseasProcurementResponses.Detail update(String customerId, String orderNo, + OverseasProcurementRequests.Update request) { + OverseasProcurementOrder order = lock(customerId, orderNo); + if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry"); + 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()); + 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"); + } + return detail(customerId, orderNo); + } + + private OverseasProcurementResponses.Detail replay(String customerId, OverseasProcurementOrder order, String hash) { + if (!hash.equals(order.getRequestHash())) { + throw conflict("Idempotency key was reused with different procurement data"); + } + return detail(customerId, order.getOrderNo()); + } + + private OverseasProcurementOrder lock(String customerId, String orderNo) { + OverseasProcurementOrder order = orders.lockNo(customerId, orderNo); + if (order == null) throw notFound("Overseas procurement order not found"); + return order; + } + + 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()); + } + + 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) { + OverseasProcurementStatusHistory history = new OverseasProcurementStatusHistory(); + history.setId(IdWorker.getId()); + history.setOrderId(order.getId()); + history.setCustomerId(order.getCustomerId()); + history.setFromStatus(from); + history.setToStatus(to); + history.setOperatorId(operator); + history.setRemark(trim(remark)); + history.setCreatedAt(now); + histories.insert(history); + } + + private void validateQuery(OverseasProcurementRequests.Query query) { + if (StringUtils.hasText(query.status()) && !STATUSES.contains(query.status().trim().toUpperCase())) { + throw badRequest("Unsupported procurement status"); + } + if (query.createdFrom() != null && query.createdTo() != null + && query.createdFrom().isAfter(query.createdTo())) throw badRequest("createdFrom must not be after createdTo"); + } + + private String hash(Object value) { + try { + byte[] json = objectMapper.writeValueAsBytes(value); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json)); + } catch (JsonProcessingException | NoSuchAlgorithmException exception) { + throw new IllegalStateException("Cannot hash procurement request", exception); + } + } + + private static void touch(OverseasProcurementOrder order, LocalDateTime now) { + order.setVersion(value(order.getVersion()) + 1); + order.setUpdatedAt(now); + } + + private static long value(Long value) { return value == null ? 0 : value; } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String defaultCurrency(String value) { return trim(value) == null ? "USD" : value.trim().toUpperCase(); } + 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); + } + 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/relabel/RelabelMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/RelabelMapperConfiguration.java new file mode 100644 index 000000000..ab033a5d3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/RelabelMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.relabel; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.relabel.mapper") +public class RelabelMapperConfiguration { +} 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 new file mode 100644 index 000000000..349c89f69 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.relabel.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.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.dto.RelabelResponses; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +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/relabel-applications") +public class RelabelApplicationController { + @Resource private RelabelApplicationService service; + + @PostMapping + 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 + 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}") + 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") + public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String relabelNo, + @Valid @RequestBody RelabelRequests.ChangeStatus request) { + return ApiResponse.success(service.changeStatus(scope(user, customerId), relabelNo, operator(user), request)); + } + private String scope(AuthenticatedUser user, String requested) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requested == null || requested.isBlank() || "0".equals(requested)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); + } + return requested.trim(); + } + return String.valueOf(user.tenantId()); + } + private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java new file mode 100644 index 000000000..32ddaea7c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.relabel.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class RelabelRequests { + private RelabelRequests() {} + + public record Create( + @NotBlank @Size(max = 128) String customerOrderNo, + @NotBlank @Size(max = 128) String overseasWarehouseId, + @NotNull LocalDateTime labelDeadline, + @NotBlank @Size(max = 128) String contactName, + @NotBlank @Size(max = 64) String contactPhone, + @Size(max = 1000) String remark, + @NotBlank @Size(max = 255) String idempotencyKey, + @NotEmpty @Size(max = 500) List<@Valid Item> items + ) {} + + public record Item( + @NotBlank @Size(max = 128) String skuCode, + @Size(max = 128) String productImageFileId, + @Size(max = 255) String productImageFileName, + @Size(max = 1000) String productImageUrl, + @NotBlank @Size(max = 255) String productNameZh, + @Positive int totalPackageCount, + @Positive int notifiedPackageCount, + @NotBlank @Size(max = 128) String shipmentNo, + @Size(max = 255) String trackingCode, + @NotBlank @Size(max = 128) String newSkuCode, + @Size(max = 1000) String labelRequirement, + @Size(max = 128) String relabelFileId, + @Size(max = 255) String relabelFileName, + @Size(max = 1000) String relabelFileUrl + ) {} + + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String relabelNo, + String customerOrderNo, String skuCode, String status, + String overseasWarehouseId, LocalDateTime createdFrom, LocalDateTime createdTo) { + public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record ChangeStatus(@NotBlank @Size(max = 32) String status, + @Size(max = 1000) String remark) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java new file mode 100644 index 000000000..f148026a7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.relabel.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class RelabelResponses { + private RelabelResponses() {} + + public record Summary(Long id, String relabelNo, Long lastlegOrderId, String lastlegOrderNo, + String customerOrderNo, String overseasWarehouseId, LocalDateTime labelDeadline, + String contactName, String contactPhone, String status, int itemCount, + int totalPackageCount, int notifiedPackageCount, String skuCodes, + LocalDateTime createdAt) {} + public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName, + String productImageUrl, String productNameZh, int totalPackageCount, + int notifiedPackageCount, String shipmentNo, String trackingCode, + String newSkuCode, String labelRequirement, String relabelFileId, + String relabelFileName, String relabelFileUrl) {} + public record History(String fromStatus, String toStatus, String operatorId, + String remark, LocalDateTime createdAt) {} + public record Detail(Summary application, String remark, List items, List history) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java new file mode 100644 index 000000000..607c2f25f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.relabel.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_relabel_application") +public class RelabelApplication { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private String relabelNo; + private Long lastlegOrderId; + private String customerOrderNo; + private String overseasWarehouseId; + private LocalDateTime labelDeadline; + private String contactName; + private String contactPhone; + private String remark; + private String status; + private Integer itemCount; + private Integer totalPackageCount; + private Integer notifiedPackageCount; + private String idempotencyKey; + private String requestHash; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelItem.java new file mode 100644 index 000000000..5dde51a26 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelItem.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.relabel.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_relabel_item") +public class RelabelItem { + @TableId(type = IdType.INPUT) private Long id; + private Long applicationId; + private String customerId; + private String skuCode; + private String productImageFileId; + private String productImageFileName; + private String productImageUrl; + private String productNameZh; + private Integer totalPackageCount; + private Integer notifiedPackageCount; + private String shipmentNo; + private String trackingCode; + private String newSkuCode; + private String labelRequirement; + private String relabelFileId; + private String relabelFileName; + private String relabelFileUrl; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelStatusHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelStatusHistory.java new file mode 100644 index 000000000..d68d2dda5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelStatusHistory.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.relabel.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_relabel_status_history") +public class RelabelStatusHistory { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long applicationId; + private String fromStatus; + private String toStatus; + private String operatorId; + private String remark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java new file mode 100644 index 000000000..692276aca --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.relabel.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface RelabelApplicationMapper extends BaseMapper { + @Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1") + RelabelApplication findNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); + + @Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + RelabelApplication findKey(@Param("customerId") String customerId, @Param("key") String key); + + @Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE") + RelabelApplication lockNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java new file mode 100644 index 000000000..1e9b91c39 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.relabel.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface RelabelItemMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelStatusHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelStatusHistoryMapper.java new file mode 100644 index 000000000..740e3b61c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelStatusHistoryMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.relabel.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface RelabelStatusHistoryMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java new file mode 100644 index 000000000..8a3fe2efa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -0,0 +1,264 @@ +package com.baiyue.lastleg.relabel.service; + +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.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.dto.RelabelResponses; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class RelabelApplicationService { + public static final String PENDING_PROCESSING = "PENDING_PROCESSING"; + private static final Set STATUSES = Set.of(PENDING_PROCESSING, "PROCESSING", "COMPLETED", "REJECTED", "CANCELLED"); + + @Resource private RelabelApplicationMapper applications; + @Resource private RelabelItemMapper items; + @Resource private RelabelStatusHistoryMapper histories; + @Resource private LastlegOrderMapper orders; + @Resource private LastlegOrderStatusHistoryMapper orderHistories; + @Resource private LastlegOrderNoGenerator orderNoGenerator; + @Resource private ObjectMapper objectMapper; + + @Transactional + public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) { + int total = 0; + int notified = 0; + for (RelabelRequests.Item item : request.items()) { + if (item.notifiedPackageCount() > item.totalPackageCount()) { + throw badRequest("Notified relocation packages cannot exceed total packages for SKU " + item.skuCode()); + } + total = Math.addExact(total, item.totalPackageCount()); + notified = Math.addExact(notified, item.notifiedPackageCount()); + } + String requestHash = hash(request); + RelabelApplication replay = applications.findKey(customerId, request.idempotencyKey().trim()); + if (replay != null) { + if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with different relabel data"); + return detail(customerId, replay.getRelabelNo()); + } + LocalDateTime now = LocalDateTime.now(); + long applicationId = IdWorker.getId(); + String relabelNo = "FBR" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + applicationId; + LastlegOrder order = new LastlegOrder(); + order.setId(IdWorker.getId()); + order.setOrderNo(orderNoGenerator.next()); + order.setCustomerId(customerId); + order.setOrderType("RELABEL_APPLICATION"); + order.setInboundCode(relabelNo); + order.setSourceRegion("CUSTOMER"); + order.setSourceInboundId(relabelNo); + order.setSourceOrderNo(request.customerOrderNo().trim()); + order.setOverseasWarehouseId(request.overseasWarehouseId().trim()); + order.setPalletCount(0); + order.setPackageCount(total); + order.setPickedPackageCount(0); + order.setShippedPackageCount(0); + order.setCurrentStatus("PENDING_ASSIGNMENT"); + order.setSubOrderStatus("RELABEL:" + PENDING_PROCESSING); + order.setRelabelOrderNo(relabelNo); + order.setRelabelStatus(PENDING_PROCESSING); + order.setCustomerRemark(trim(request.remark())); + order.setSourceEventId("CUSTOMER_RELABEL:" + relabelNo); + order.setSourceLoadingPlanId("RELABEL_APPLICATION"); + order.setSourcePayloadHash("sha256:" + requestHash); + order.setCreatedAt(now); + order.setUpdatedAt(now); + orders.insert(order); + + RelabelApplication application = new RelabelApplication(); + application.setId(applicationId); + application.setCustomerId(customerId); + application.setRelabelNo(relabelNo); + application.setLastlegOrderId(order.getId()); + application.setCustomerOrderNo(request.customerOrderNo().trim()); + application.setOverseasWarehouseId(request.overseasWarehouseId().trim()); + application.setLabelDeadline(request.labelDeadline()); + application.setContactName(request.contactName().trim()); + application.setContactPhone(request.contactPhone().trim()); + application.setRemark(trim(request.remark())); + application.setStatus(PENDING_PROCESSING); + application.setItemCount(request.items().size()); + application.setTotalPackageCount(total); + application.setNotifiedPackageCount(notified); + application.setIdempotencyKey(request.idempotencyKey().trim()); + application.setRequestHash(requestHash); + application.setCreatedBy(trim(operatorId)); + application.setCreatedAt(now); + application.setUpdatedAt(now); + applications.insert(application); + + for (RelabelRequests.Item source : request.items()) insertItem(customerId, applicationId, source, now); + insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now); + insertOrderHistory(order.getId(), null, order.getCurrentStatus(), "Relabel application submitted", now); + return detail(customerId, relabelNo); + } + + @Transactional(readOnly = true) + public PageResult page(String customerId, RelabelRequests.Query query) { + if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { + throw badRequest("createdFrom must not be after createdTo"); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(RelabelApplication::getCustomerId, customerId) + .like(StringUtils.hasText(query.relabelNo()), RelabelApplication::getRelabelNo, query.relabelNo()) + .like(StringUtils.hasText(query.customerOrderNo()), RelabelApplication::getCustomerOrderNo, query.customerOrderNo()) + .eq(StringUtils.hasText(query.status()), RelabelApplication::getStatus, query.status()) + .eq(StringUtils.hasText(query.overseasWarehouseId()), RelabelApplication::getOverseasWarehouseId, query.overseasWarehouseId()) + .ge(query.createdFrom() != null, RelabelApplication::getCreatedAt, query.createdFrom()) + .le(query.createdTo() != null, RelabelApplication::getCreatedAt, query.createdTo()) + .apply(StringUtils.hasText(query.skuCode()), + "EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=ll_relabel_application.id AND ri.customer_id=ll_relabel_application.customer_id AND ri.sku_code LIKE CONCAT('%',{0},'%'))", + query.skuCode()) + .orderByDesc(RelabelApplication::getCreatedAt); + Page page = applications.selectPage(new Page<>(query.page(), query.size()), wrapper); + List records = page.getRecords().stream().map(value -> summary(value, itemList(value.getId()))).toList(); + return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); + } + + @Transactional(readOnly = true) + public RelabelResponses.Detail detail(String customerId, String relabelNo) { + RelabelApplication application = require(customerId, relabelNo); + List detailItems = itemList(application.getId()); + List statusHistory = histories.selectList(new LambdaQueryWrapper() + .eq(RelabelStatusHistory::getCustomerId, customerId) + .eq(RelabelStatusHistory::getApplicationId, application.getId()) + .orderByAsc(RelabelStatusHistory::getCreatedAt)); + return new RelabelResponses.Detail(summary(application, detailItems), application.getRemark(), + detailItems.stream().map(this::item).toList(), statusHistory.stream().map(value -> + new RelabelResponses.History(value.getFromStatus(), value.getToStatus(), value.getOperatorId(), + value.getRemark(), value.getCreatedAt())).toList()); + } + + @Transactional + public RelabelResponses.Detail changeStatus(String customerId, String relabelNo, String operatorId, + RelabelRequests.ChangeStatus request) { + RelabelApplication application = applications.lockNo(customerId, relabelNo); + if (application == null) throw notFound("Relabel application not found"); + String next = request.status().trim().toUpperCase(); + if (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status"); + if (next.equals(application.getStatus())) return detail(customerId, relabelNo); + validateTransition(application.getStatus(), next); + String before = application.getStatus(); + LocalDateTime now = LocalDateTime.now(); + application.setStatus(next); + application.setUpdatedAt(now); + applications.updateById(application); + LastlegOrder order = orders.selectById(application.getLastlegOrderId()); + if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Linked tail-leg order is missing"); + String orderBefore = order.getCurrentStatus(); + String mainStatus = STATUSES.contains(orderBefore) ? "PENDING_ASSIGNMENT" : orderBefore; + // A return-relabel tail order joins the normal inbound queue after labels are finished. + String orderStatus = switch (next) { + case "COMPLETED" -> "RETURN_RELABEL".equals(order.getOrderType()) + ? "PENDING_ASSIGNMENT" : "COMPLETED"; + case "REJECTED", "CANCELLED" -> "CANCELLED"; + default -> mainStatus; + }; + order.setCurrentStatus(orderStatus); + order.setSubOrderStatus("RELABEL:" + next); + order.setRelabelStatus(next); + order.setUpdatedAt(now); + orders.updateById(order); + insertHistory(customerId, application.getId(), before, next, operatorId, request.remark(), now); + if (!Objects.equals(orderBefore, orderStatus)) { + insertOrderHistory(order.getId(), orderBefore, orderStatus, request.remark(), now); + } + return detail(customerId, relabelNo); + } + + private void insertItem(String customerId, long applicationId, RelabelRequests.Item source, LocalDateTime now) { + RelabelItem item = new RelabelItem(); + item.setId(IdWorker.getId()); item.setApplicationId(applicationId); item.setCustomerId(customerId); + item.setSkuCode(source.skuCode().trim()); item.setProductImageFileId(trim(source.productImageFileId())); + item.setProductImageFileName(trim(source.productImageFileName())); item.setProductImageUrl(trim(source.productImageUrl())); + item.setProductNameZh(source.productNameZh().trim()); item.setTotalPackageCount(source.totalPackageCount()); + item.setNotifiedPackageCount(source.notifiedPackageCount()); item.setShipmentNo(source.shipmentNo().trim()); + item.setTrackingCode(trim(source.trackingCode())); item.setNewSkuCode(source.newSkuCode().trim()); + item.setLabelRequirement(trim(source.labelRequirement())); item.setRelabelFileId(trim(source.relabelFileId())); + item.setRelabelFileName(trim(source.relabelFileName())); item.setRelabelFileUrl(trim(source.relabelFileUrl())); + item.setCreatedAt(now); items.insert(item); + } + + private RelabelResponses.Summary summary(RelabelApplication value, List detailItems) { + LastlegOrder order = orders.selectById(value.getLastlegOrderId()); + String skuCodes = detailItems.stream().map(RelabelItem::getSkuCode).filter(Objects::nonNull).distinct().collect(Collectors.joining(",")); + return new RelabelResponses.Summary(value.getId(), value.getRelabelNo(), value.getLastlegOrderId(), + order == null ? null : order.getOrderNo(), value.getCustomerOrderNo(), value.getOverseasWarehouseId(), + value.getLabelDeadline(), value.getContactName(), value.getContactPhone(), value.getStatus(), + Objects.requireNonNullElse(value.getItemCount(), detailItems.size()), + Objects.requireNonNullElse(value.getTotalPackageCount(), 0), + Objects.requireNonNullElse(value.getNotifiedPackageCount(), 0), skuCodes, value.getCreatedAt()); + } + + private RelabelResponses.Item item(RelabelItem value) { + return new RelabelResponses.Item(value.getId(), value.getSkuCode(), value.getProductImageFileId(), + value.getProductImageFileName(), value.getProductImageUrl(), value.getProductNameZh(), + value.getTotalPackageCount(), value.getNotifiedPackageCount(), value.getShipmentNo(), + value.getTrackingCode(), value.getNewSkuCode(), value.getLabelRequirement(), value.getRelabelFileId(), + value.getRelabelFileName(), value.getRelabelFileUrl()); + } + + private List itemList(Long applicationId) { + return items.selectList(new LambdaQueryWrapper().eq(RelabelItem::getApplicationId, applicationId) + .orderByAsc(RelabelItem::getId)); + } + private RelabelApplication require(String customerId, String relabelNo) { + RelabelApplication value = applications.findNo(customerId, relabelNo); + if (value == null) throw notFound("Relabel application not found"); + return value; + } + private void insertHistory(String customerId, Long id, String from, String to, String operator, + String remark, LocalDateTime now) { + RelabelStatusHistory value = new RelabelStatusHistory(); value.setId(IdWorker.getId()); value.setCustomerId(customerId); + value.setApplicationId(id); value.setFromStatus(from); value.setToStatus(to); value.setOperatorId(trim(operator)); + value.setRemark(trim(remark)); value.setCreatedAt(now); histories.insert(value); + } + private void insertOrderHistory(Long id, String from, String to, String remark, LocalDateTime now) { + LastlegOrderStatusHistory value = new LastlegOrderStatusHistory(); value.setOrderId(id); value.setFromStatus(from); + value.setToStatus(to); value.setEventId("RELABEL_APPLICATION"); value.setRemark(trim(remark)); value.setCreatedAt(now); + orderHistories.insert(value); + } + private static void validateTransition(String from, String to) { + boolean allowed = PENDING_PROCESSING.equals(from) && Set.of("PROCESSING", "REJECTED", "CANCELLED").contains(to) + || "PROCESSING".equals(from) && Set.of("COMPLETED", "REJECTED", "CANCELLED").contains(to); + if (!allowed) throw conflict("Relabel status cannot change from " + from + " to " + to); + } + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw new IllegalStateException(exception); } + } + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + 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/repository/package-info.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/package-info.java new file mode 100644 index 000000000..b3cd17d05 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/package-info.java @@ -0,0 +1,5 @@ +/** + * Tenant-scoped aggregate queries and multi-mapper persistence composition. + * / 租户范围聚合查询及多 Mapper 持久化组合。 + */ +package com.baiyue.lastleg.repository; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java new file mode 100644 index 000000000..ba4ef399b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@Component +public class InternalServiceTokenGuard { + private final String expectedToken; + public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token:}") String expectedToken) { this.expectedToken=expectedToken; } + public void verify(String actualToken) { + if(expectedToken==null||expectedToken.isBlank()) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"内部事件接收令牌未配置"); + byte[] expected=expectedToken.getBytes(StandardCharsets.UTF_8); + byte[] actual=actualToken==null?new byte[0]:actualToken.getBytes(StandardCharsets.UTF_8); + if(!MessageDigest.isEqual(expected,actual)) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"内部服务令牌无效"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java new file mode 100644 index 000000000..86b89766f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import org.springframework.stereotype.Component; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +@Component +public class LastlegOrderNoGenerator { + public String next() { + return "LMO" + IdWorker.getIdStr(); + } + + /** The physical container number is the tail-leg business order grouping boundary. */ + public String forContainerNo(String containerNo) { + if (containerNo == null || containerNo.isBlank()) { + throw new IllegalArgumentException("containerNo is required"); + } + String normalized = containerNo.trim(); + if (normalized.matches("[0-9]{1,29}")) { + return "LMO" + normalized; + } + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(normalized.getBytes(StandardCharsets.UTF_8)); + String digits = new BigInteger(1, digest).toString(); + return "LMO" + digits.substring(0, Math.min(29, digits.length())); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } +} 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 new file mode 100644 index 000000000..414d2067f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -0,0 +1,202 @@ +package com.baiyue.lastleg.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +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; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +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 { + public static final String PENDING_ASSIGNMENT = "PENDING_ASSIGNMENT"; + public static final String DESTINATION_ASSIGNED = "DESTINATION_ASSIGNED"; + public static final String INBOUND_PLANNED = "INBOUND_PLANNED"; + + @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( + new Page<>(query.page(), query.size()), filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt)); + return new PageResult<>(page.getRecords().stream().map(this::summary).toList(), + page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); + } + + public LastlegOrderDetailResponse detail(String customerId, long orderId) { + LastlegOrder order = findOwned(customerId, orderId); + List cargo = detailMapper.selectList(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getOrderId, orderId).orderByAsc(LastlegOrderDetail::getId)); + List history = historyMapper.selectList( + 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(); + } + + @Transactional + public int transferDestination(String customerId, LastlegOrderRequests.TransferDestination request) { + List ids = request.orderIds().stream().filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one order is required"); + } + List orders = orderMapper.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, ids).last("FOR UPDATE")); + if (orders.size() != ids.size()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more tail-leg orders were not found"); + } + for (LastlegOrder order : orders) { + ensureTransferable(order); + } + LocalDateTime now = LocalDateTime.now(); + for (LastlegOrder order : orders) { + String fromStatus = order.getCurrentStatus(); + order.setTransferWarehouseId(request.warehouseId()); + order.setTransferWarehouseCode(request.warehouseCode()); + order.setTransferWarehouseName(request.warehouseName()); + order.setCurrentStatus(DESTINATION_ASSIGNED); + order.setUpdatedAt(now); + orderMapper.updateById(order); + + LastlegOrderDetail detailUpdate = new LastlegOrderDetail(); + detailUpdate.setDestinationWarehouseId(request.warehouseId()); + detailUpdate.setDestinationWarehouseCode(request.warehouseCode()); + detailUpdate.setDestinationWarehouseName(request.warehouseName()); + detailMapper.update(detailUpdate, new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getOrderId, order.getId())); + + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); + history.setOrderId(order.getId()); + history.setFromStatus(fromStatus); + history.setToStatus(DESTINATION_ASSIGNED); + history.setRemark(StringUtils.hasText(request.remark()) + ? request.remark() : "Destination warehouse assigned: " + request.warehouseCode()); + history.setCreatedAt(now); + historyMapper.insert(history); + } + return orders.size(); + } + + public byte[] exportCsv(String customerId, LastlegOrderRequests.Query query) { + List orders = orderMapper.selectList(filter(customerId, query) + .orderByDesc(LastlegOrder::getCreatedAt).last("LIMIT 10000")); + List lines = new ArrayList<>(); + 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.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.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()), + csv(order.getCustomerRemark()), csv(order.getWarehouseRemark())))); + } + return ("\uFEFF" + String.join("\r\n", lines)).getBytes(StandardCharsets.UTF_8); + } + + private LambdaQueryWrapper filter(String customerId, LastlegOrderRequests.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"); + } + return new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.orderNo()), LastlegOrder::getOrderNo, query.orderNo()) + .like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode()) + .like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo()) + .like(StringUtils.hasText(query.containerNo()), LastlegOrder::getContainerNo, query.containerNo()) + .eq(StringUtils.hasText(query.status()), LastlegOrder::getCurrentStatus, query.status()) + .eq(StringUtils.hasText(query.transferWarehouseCode()), LastlegOrder::getTransferWarehouseCode, + query.transferWarehouseCode()) + .eq(query.abnormal() != null, LastlegOrder::getAbnormal, query.abnormal()) + .eq(query.detained() != null, LastlegOrder::getDetained, query.detained()) + .ge(query.createdFrom() != null, LastlegOrder::getCreatedAt, query.createdFrom()) + .le(query.createdTo() != null, LastlegOrder::getCreatedAt, query.createdTo()); + } + + private LastlegOrder findOwned(String customerId, long orderId) { + LastlegOrder order = orderMapper.selectOne(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getId, orderId)); + if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found"); + return order; + } + + private void ensureTransferable(LastlegOrder order) { + if (StringUtils.hasText(order.getInboundOrderNo()) || INBOUND_PLANNED.equals(order.getCurrentStatus())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Destination warehouse cannot be changed after inbound plan creation: " + order.getId()); + } + } + + private LastlegOrderSummary summary(LastlegOrder o) { + return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(), o.getSourceInboundId(), + 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.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()); + } + + private static String csv(Object value) { + if (value == null) return ""; + String text = String.valueOf(value); + if (!text.isEmpty() && "=+-@".indexOf(text.charAt(0)) >= 0) text = "'" + text; + return '"' + text.replace("\"", "\"\"") + '"'; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java new file mode 100644 index 000000000..49fa9caef --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -0,0 +1,203 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.common.event.*; +import com.baiyue.lastleg.entity.*; +import com.baiyue.lastleg.mapper.*; +import com.baiyue.lastleg.dropship.dto.DropshipRequests; +import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.DeserializationFeature; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.ArrayList; +import java.util.List; +import com.fasterxml.jackson.databind.JsonNode; + +@Service +public class LoadingCompletedConsumer { + @Resource private LastlegEventInboxMapper inboxMapper; + @Resource private LastlegOrderMapper orderMapper; + @Resource private LastlegOrderDetailMapper detailMapper; + @Resource private LastlegOrderStatusHistoryMapper historyMapper; + @Resource private ObjectMapper objectMapper; + @Resource private LastlegOrderNoGenerator orderNoGenerator; + @Resource private DropshipOrderService dropshipOrders; + + @Transactional + public int consume(EventEnvelope envelope) { + envelope.requireValid(); + if(!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType())||envelope.eventVersion()!=1) + throw new IllegalArgumentException("不支持的尾程交接事件"); + if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) + throw new IllegalArgumentException("尾程交接事件区域无效"); + if (!"LOADING_PLAN".equals(envelope.aggregateType())) + throw new IllegalArgumentException("尾程交接事件聚合类型无效"); + FirstLegLoadingPlanCompletedV1 payload = parsePayload(envelope); + if(payload.containerNo()==null||payload.containerNo().isBlank()) + throw new IllegalArgumentException("事件没有实际集装箱号 containerNo"); + if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单"); + LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId()); + if(existing!=null) { + if(!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) throw new IllegalArgumentException("事件摘要冲突"); + return countOrders(envelope,payload.containerNo()); + } + + LastlegEventInbox inbox=new LastlegEventInbox(); + inbox.setEventId(envelope.eventId()); inbox.setCustomerId(envelope.tenantId()); inbox.setEventType(envelope.eventType()); + inbox.setEventVersion(envelope.eventVersion()); inbox.setPayloadHash(envelope.payloadHash()); + inbox.setPayloadJson(envelope.payload().toString()); inbox.setStatus("RECEIVED"); inbox.setReceivedAt(LocalDateTime.now()); + try { inboxMapper.insert(inbox); } catch(DuplicateKeyException exception) { + return countOrders(envelope,payload.containerNo()); + } + + String orderNo = orderNoGenerator.forContainerNo(payload.containerNo()); + JsonNode rawOrders = envelope.payload().path("orders"); + for (int index = 0; index < payload.orders().size(); index++) { + var source = payload.orders().get(index); + JsonNode rawSource = rawOrders.isArray() && index < rawOrders.size() ? rawOrders.get(index) : objectMapper.createObjectNode(); + createOrder(envelope, payload, source, orderNo, rawSource); + } + inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxMapper.updateById(inbox); + return payload.orders().size(); + } + + private FirstLegLoadingPlanCompletedV1 parsePayload(EventEnvelope envelope) { + try { + ObjectMapper compatibleMapper = objectMapper.copy() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + return compatibleMapper.treeToValue(envelope.payload(), FirstLegLoadingPlanCompletedV1.class); + } + catch(Exception exception) { throw new IllegalArgumentException("尾程事件负载无效",exception); } + } + + private int countOrders(EventEnvelope envelope,String containerNo) { + return Math.toIntExact(orderMapper.countContainer(envelope.tenantId(),containerNo)); + } + + private void createOrder(EventEnvelope event,FirstLegLoadingPlanCompletedV1 payload, + FirstLegLoadingPlanCompletedV1.Order source,String orderNo, JsonNode rawSource) { + String orderHash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(rawSource)); + LastlegOrder existing=orderMapper.findSource(event.tenantId(),event.sourceRegion().name(),source.sourceInboundId()); + if(existing!=null) { + if(!Objects.equals(existing.getSourcePayloadHash(),orderHash)) throw new IllegalStateException("来源入仓单快照冲突: "+source.sourceInboundId()); + return; + } + LastlegOrder order=new LastlegOrder(); + String orderType = source.orderType(); + String customerId = source.customerId() == null || source.customerId().isBlank() + ? event.tenantId() : source.customerId(); + if (!event.tenantId().equals(customerId)) { + throw new IllegalArgumentException("Order customerId does not match the trusted event customer"); + } + List dropshipPallets = "ONE_CLICK_DROPSHIP".equals(orderType) + ? pallets(source, rawSource) : List.of(); + if ("ONE_CLICK_DROPSHIP".equals(orderType) && dropshipPallets.isEmpty()) { + throw new IllegalArgumentException("One-click dropship event has no pallet snapshot"); + } + order.setId(IdWorker.getId()); order.setOrderNo(orderNo); order.setCustomerId(customerId); order.setOrderType(orderType); + order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name()); + order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId()); + order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId()); + order.setOverseasWarehouseId(payload.warehouseId()); order.setOverseasWarehouseName(payload.warehouseName()); + order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(payload.containerNo()); + order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false); + order.setPackageCount(source.packageCount()); order.setPalletCount(dropshipPallets.size()); + order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm()); + order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg) + .filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add)); + order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId()); + order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceLoadingPlanVersion(event.aggregateVersion()); + order.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash); + order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now()); + try { + orderMapper.insert(order); + } catch (DuplicateKeyException exception) { + LastlegOrder raced = orderMapper.findSource(event.tenantId(), event.sourceRegion().name(), + source.sourceInboundId()); + if (raced != null && Objects.equals(raced.getSourcePayloadHash(), orderHash)) return; + throw exception; + } + + for(var item:source.items()) { + LastlegOrderDetail detail=new LastlegOrderDetail(); + detail.setOrderId(order.getId()); detail.setSourceInboundDetailId(item.sourceInboundDetailId()); detail.setSkuCode(item.skuCode()); + detail.setProductNameZh(item.productNameZh()); detail.setProductNameEn(item.productNameEn()); detail.setPackageCount(item.packageCount()); + detail.setProductQuantity(item.productQuantity()); detail.setPalletCount(item.palletCount()); detail.setGrossWeightKg(item.grossWeightKg()); + detail.setNetWeightKg(item.netWeightKg()); detail.setDimensionalWeightKg(item.dimensionalWeightKg()); detail.setVolumeCbm(item.volumeCbm()); + detail.setDestinationWarehouseId(item.destinationWarehouseId()); detail.setDestinationWarehouseCode(item.destinationWarehouseCode()); + detail.setDestinationWarehouseName(item.destinationWarehouseName()); detail.setFbaTrackingNo(item.fbaTrackingNo()); + detail.setShipmentNo(item.shipmentNo()); detail.setShippingMark(item.shippingMark()); detailMapper.insert(detail); + } + LastlegOrderStatusHistory history=new LastlegOrderStatusHistory(); + history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId()); + history.setRemark("头程装柜完成自动生成"); history.setCreatedAt(LocalDateTime.now()); historyMapper.insert(history); + if ("ONE_CLICK_DROPSHIP".equals(orderType)) { + long warehouseId; + try { warehouseId = Long.parseLong(payload.warehouseId()); } + catch (Exception exception) { throw new IllegalArgumentException("One-click dropship warehouseId is invalid", exception); } + dropshipOrders.receive(new DropshipRequests.Receive(customerId, order.getId(), warehouseId, + event.eventId() + ":DROPSHIP", dropshipPallets)); + } + } + + private List pallets(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) { + if (source.pallets() != null && !source.pallets().isEmpty()) { + return source.pallets().stream().map(pallet -> new DropshipRequests.Pallet(pallet.palletNo(), + pallet.skuPackages().stream().map(sku -> new DropshipRequests.SkuPackages( + sku.skuCode(), sku.productName(), sku.packageCount(), sku.productQuantity(), + sku.cartonMarks())).toList())).toList(); + } + return legacyPallets(rawSource); + } + + private List legacyPallets(JsonNode source) { + List result = new ArrayList<>(); + JsonNode nodes = source.path("pallets"); + if (nodes.isArray()) { + for (JsonNode pallet : nodes) { + String palletNo = pallet.path("palletNo").asText(pallet.path("sourcePalletNo").asText(null)); + List skuRows = skuPackages(pallet); + if (palletNo != null && !palletNo.isBlank() && !skuRows.isEmpty()) { + result.add(new DropshipRequests.Pallet(palletNo, skuRows)); + } + } + return result; + } + String palletNo = source.path("palletNo").asText(source.path("sourcePalletNo").asText(null)); + List skuRows = skuPackages(source); + if (palletNo != null && !palletNo.isBlank() && !skuRows.isEmpty()) { + result.add(new DropshipRequests.Pallet(palletNo, skuRows)); + } + return result; + } + + private List skuPackages(JsonNode source) { + List result = new ArrayList<>(); + JsonNode nodes = source.path("skuPackages"); + if (!nodes.isArray()) nodes = source.path("items"); + if (!nodes.isArray()) return result; + for (JsonNode item : nodes) { + String sku = item.path("skuCode").asText(null); + long packageCount = item.path("packageCount").asLong(0); + long productQuantity = item.path("productQuantity").asLong(0); + JsonNode markNodes = item.path("cartonMarks"); + if (!markNodes.isArray()) markNodes = item.path("boxMarks"); + if (!markNodes.isArray()) markNodes = item.path("cartonNos"); + List marks = new ArrayList<>(); + if (markNodes.isArray()) { + for (JsonNode mark : markNodes) if (!mark.asText().isBlank()) marks.add(mark.asText()); + } + if (sku != null && !sku.isBlank() && packageCount > 0 && productQuantity > 0 && !marks.isEmpty()) { + String productName = item.path("productName").asText(item.path("productNameZh").asText(null)); + result.add(new DropshipRequests.SkuPackages(sku, productName, packageCount, productQuantity, marks)); + } + } + return result; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/package-info.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/package-info.java new file mode 100644 index 000000000..01f4a18ae --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/package-info.java @@ -0,0 +1,5 @@ +/** + * Business orchestration and transaction boundaries for last-leg delivery. + * / 尾程配送业务编排与事务边界。 + */ +package com.baiyue.lastleg.service; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/TransferMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/TransferMapperConfiguration.java new file mode 100644 index 000000000..17bc09bb8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/TransferMapperConfiguration.java @@ -0,0 +1,4 @@ +package com.baiyue.lastleg.transfer; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; +@Configuration @MapperScan("com.baiyue.lastleg.transfer.mapper") public class TransferMapperConfiguration {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java new file mode 100644 index 000000000..6165b04fc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java @@ -0,0 +1,93 @@ +package com.baiyue.lastleg.transfer.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses; +import com.baiyue.lastleg.transfer.service.WarehouseTransferService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +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/warehouse-transfers") +public class WarehouseTransferController { + public static final String MANAGE_PERMISSION = "lastleg:warehouse-transfer:manage"; + @Resource private WarehouseTransferService service; + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid @RequestBody WarehouseTransferRequests.Create request) { + return ApiResponse.success(service.create(scope(user, customerId), operator(user), request)); + } + + @GetMapping("/{transferNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String transferNo) { + return ApiResponse.success(service.detail(scope(user, customerId), transferNo)); + } + + @PostMapping("/{transferNo}/review") + public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.Review request) { + requireApprovalPermission(user); + return ApiResponse.success(service.review(scope(user, customerId), transferNo, operator(user), request)); + } + + @PostMapping("/{transferNo}/allocations") + public ApiResponse allocate(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.Allocate request) { + requirePermission(user, MANAGE_PERMISSION); + return ApiResponse.success(service.allocate(scope(user, customerId), transferNo, operator(user), request)); + } + + @PostMapping("/{transferNo}/outbound") + public ApiResponse outbound(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.Outbound request) { + requirePermission(user, MANAGE_PERMISSION); + return ApiResponse.success(service.outbound(scope(user, customerId), transferNo, operator(user), request)); + } + + @PostMapping("/{transferNo}/receipts") + public ApiResponse receive(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.Receipt request) { + requirePermission(user, MANAGE_PERMISSION); + return ApiResponse.success(service.receive(scope(user, customerId), transferNo, operator(user), request)); + } + + private void requireApprovalPermission(AuthenticatedUser user) { + if (user == null || user.permissions() == null + || !user.permissions().contains("lastleg:warehouse-transfer:approve")) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Warehouse transfer approval permission is required"); + } + } + + private void requirePermission(AuthenticatedUser user, String permission) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (user.permissions() == null || !user.permissions().contains(permission)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, permission + " permission is required"); + } + } + + private String scope(AuthenticatedUser user, String requested) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requested == null || requested.isBlank() || "0".equals(requested)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); + } + return requested.trim(); + } + return String.valueOf(user.tenantId()); + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java new file mode 100644 index 000000000..efa13b0c1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java @@ -0,0 +1,76 @@ +package com.baiyue.lastleg.transfer.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.DecimalMin; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public final class WarehouseTransferRequests { + private WarehouseTransferRequests() {} + + public record Create( + @NotBlank @Size(max = 128) String inboundCode, + @NotEmpty @Size(max = 200) List<@NotNull Long> sourceLastlegOrderIds, + @NotBlank @Size(max = 128) String operatingWarehouseId, + @NotBlank @Size(max = 128) String fbaWarehouseCode, + @NotBlank @Size(max = 1000) String deliveryAddress, + @Positive int packageCount, + @NotNull @DecimalMin(value = "0.0001") BigDecimal volumeCbm, + @NotBlank @Size(max = 64) String deliveryMethod, + @NotNull LocalDateTime estimatedArrivalAt, + @NotBlank @Size(max = 128) String fbaShipmentNo, + @Size(max = 255) String trackingNo, + @Size(max = 2000) String fbaBoxNos, + @Size(max = 1000) String remark, + boolean needRelabel, + @NotBlank @Size(max = 255) String idempotencyKey, + @NotEmpty @Size(max = 500) List<@Valid Item> items + ) {} + + public record Review(@NotBlank @Size(max = 32) String decision, + @Size(max = 1000) String remark) {} + + public record Allocate( + @NotBlank @Size(max = 255) String idempotencyKey, + @NotEmpty @Size(max = 500) List<@Valid Allocation> allocations + ) {} + + public record Allocation( + @NotNull Long inventoryPositionId, + @NotNull Long palletId, + @Positive int packageCount + ) {} + + public record Outbound(@NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record Receipt( + @NotNull Long sourceInventoryPositionId, + @NotBlank @Size(max = 128) String destinationOverseasWarehouseId, + @Size(max = 128) String destinationOverseasWarehouseCode, + @Size(max = 255) String destinationOverseasWarehouseName, + @Size(max = 128) String destinationWarehouseId, + @NotBlank @Size(max = 128) String destinationWarehouseCode, + @Size(max = 255) String destinationWarehouseName, + @NotNull Long destinationPalletId, + @NotBlank @Size(max = 128) String destinationPalletNo, + @Size(max = 128) String batchNo, + Long warehouseLocationId, + @Size(max = 128) String warehouseLocationCode, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark + ) {} + + public record Item( + @NotBlank @Size(max = 128) String skuCode, + @NotBlank @Size(max = 255) String productNameZh, + @Positive int packageCount, + @Size(max = 255) String shippingMark + ) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java new file mode 100644 index 000000000..38d8bcf12 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.transfer.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class WarehouseTransferResponses { + private WarehouseTransferResponses() {} + public record Item(String skuCode, String productNameZh, int packageCount, String shippingMark) {} + public record SourceOrder(Long lastlegOrderId, String lastlegOrderNo, Long inboundOrderId, String inboundCode) {} + public record Allocation(Long inventoryPositionId, Long inboundOrderId, String inboundCode, + Long palletId, String palletNo, int packageCount, String status, + Long destinationInventoryPositionId) {} + public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode, + Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus, + boolean needRelabel, Long relabelApplicationId, String relabelNo, String relabelStatus, + String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress, + int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt, + String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark, + List sourceOrders, List items, List allocations, + LocalDateTime outboundAt, LocalDateTime createdAt) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferInventoryAllocation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferInventoryAllocation.java new file mode 100644 index 000000000..1133aa030 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferInventoryAllocation.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.transfer.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_warehouse_transfer_inventory_allocation") +public class WarehouseTransferInventoryAllocation { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private String customerId; + private Long inventoryPositionId; + private Long inventoryBalanceId; + private Long inboundOrderId; + private String inboundCode; + private Long palletId; + private String palletNo; + private Integer packageCount; + private String status; + private String activePositionKey; + private LocalDateTime allocatedAt; + private LocalDateTime shippedAt; + private LocalDateTime receivedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferItem.java new file mode 100644 index 000000000..6ca13d301 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferItem.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.transfer.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_warehouse_transfer_item") +public class WarehouseTransferItem { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private String customerId; + private String skuCode; + private String productNameZh; + private Integer packageCount; + private String shippingMark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java new file mode 100644 index 000000000..56e8acae9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.transfer.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_warehouse_transfer_order") +public class WarehouseTransferOrder { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private String transferNo; + private Long inboundOrderId; + private String inboundCode; + private Long lastlegOrderId; + private String lastlegOrderNo; + private String operatingWarehouseId; + private String fbaWarehouseCode; + private String deliveryAddress; + private Integer packageCount; + private String deliveryMethod; + private LocalDateTime estimatedArrivalAt; + private String fbaShipmentNo; + private String trackingNo; + private String fbaBoxNos; + private String remark; + private Boolean needRelabel; + private Long relabelApplicationId; + private String relabelNo; + private String status; + private String allocationIdempotencyKey; + private String allocationRequestHash; + private String outboundIdempotencyKey; + private String outboundRequestHash; + private LocalDateTime outboundAt; + private String idempotencyKey; + private String requestHash; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferReceipt.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferReceipt.java new file mode 100644 index 000000000..ac50dafb5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferReceipt.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.transfer.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_warehouse_transfer_receipt") +public class WarehouseTransferReceipt { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private Long allocationId; + private String customerId; + private Long sourceInventoryPositionId; + private Long destinationInventoryBalanceId; + private Long destinationInventoryPositionId; + private Integer packageCount; + private String idempotencyKey; + private String requestHash; + private String inventoryOperationKey; + private String createdBy; + private LocalDateTime receivedAt; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferSourceOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferSourceOrder.java new file mode 100644 index 000000000..c760c7000 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferSourceOrder.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.transfer.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_warehouse_transfer_source_order") +public class WarehouseTransferSourceOrder { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private String customerId; + private Long sourceLastlegOrderId; + private String sourceLastlegOrderNo; + private Long inboundOrderId; + private String inboundCode; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java new file mode 100644 index 000000000..59e14637c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.transfer.mapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +@Mapper public interface InboundSnapshotMapper { + @Select("SELECT id FROM ll_inbound_order WHERE customer_id=#{customerId} AND (inbound_code=#{code} OR source_inbound_id=#{code}) LIMIT 1") + Long findId(@Param("customerId") String customerId, @Param("code") String code); + @Select("SELECT id FROM ll_inbound_order WHERE customer_id=#{customerId} AND source_inbound_id=#{sourceInboundId} LIMIT 1") + Long findBySourceInboundId(@Param("customerId") String customerId, @Param("sourceInboundId") String sourceInboundId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferInventoryAllocationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferInventoryAllocationMapper.java new file mode 100644 index 000000000..858e80d15 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferInventoryAllocationMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.transfer.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation; +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 WarehouseTransferInventoryAllocationMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_inventory_allocation WHERE customer_id=#{customerId} " + + "AND transfer_order_id=#{transferOrderId} ORDER BY id FOR UPDATE") + List lockTransfer(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId); + + @Select("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} " + + "AND warehouse_pallet_id=#{palletId} AND status='ACTIVE'") + int countActiveDispatchLock(@Param("customerId") String customerId, @Param("palletId") Long palletId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferItemMapper.java new file mode 100644 index 000000000..7de29cb2c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferItemMapper.java @@ -0,0 +1,5 @@ +package com.baiyue.lastleg.transfer.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem; +import org.apache.ibatis.annotations.Mapper; +@Mapper public interface WarehouseTransferItemMapper extends BaseMapper {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java new file mode 100644 index 000000000..e062027c7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.transfer.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +@Mapper public interface WarehouseTransferOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + WarehouseTransferOrder findKey(@Param("customerId") String customerId, @Param("key") String key); + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} LIMIT 1") + WarehouseTransferOrder findNo(@Param("customerId") String customerId, @Param("transferNo") String transferNo); + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} LIMIT 1 FOR UPDATE") + WarehouseTransferOrder lockNo(@Param("customerId") String customerId, @Param("transferNo") String transferNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferReceiptMapper.java new file mode 100644 index 000000000..24f8b3ff1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferReceiptMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.transfer.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface WarehouseTransferReceiptMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_receipt WHERE customer_id=#{customerId} " + + "AND transfer_order_id=#{transferOrderId} AND source_inventory_position_id=#{sourcePositionId} LIMIT 1") + WarehouseTransferReceipt findSource(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId, + @Param("sourcePositionId") Long sourcePositionId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferSourceOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferSourceOrderMapper.java new file mode 100644 index 000000000..a501a5110 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferSourceOrderMapper.java @@ -0,0 +1,5 @@ +package com.baiyue.lastleg.transfer.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder; +import org.apache.ibatis.annotations.Mapper; +@Mapper public interface WarehouseTransferSourceOrderMapper extends BaseMapper {} 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 new file mode 100644 index 000000000..5abcb23ce --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -0,0 +1,536 @@ +package com.baiyue.lastleg.transfer.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; +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.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder; +import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HexFormat; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class WarehouseTransferService { + public static final String ORDER_TYPE = "WAREHOUSE_TRANSFER"; + public static final String RETURN_RELABEL_ORDER_TYPE = "RETURN_RELABEL"; + public static final String PENDING_REVIEW = "PENDING_REVIEW"; + public static final String APPROVED = "APPROVED"; + public static final String REJECTED = "REJECTED"; + public static final String PENDING_ASSIGNMENT = "PENDING_ASSIGNMENT"; + public static final String ALLOCATED = "ALLOCATED"; + public static final String OUTBOUNDED = "OUTBOUNDED"; + public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED"; + public static final String RECEIVED = "RECEIVED"; + + @Resource private WarehouseTransferOrderMapper transfers; + @Resource private WarehouseTransferItemMapper transferItems; + @Resource private WarehouseTransferSourceOrderMapper sourceOrders; + @Resource private WarehouseTransferInventoryAllocationMapper inventoryAllocations; + @Resource private WarehouseTransferReceiptMapper transferReceipts; + @Resource private InboundSnapshotMapper inboundSnapshots; + @Resource private LastlegOrderMapper orders; + @Resource private LastlegOrderDetailMapper orderDetails; + @Resource private LastlegOrderStatusHistoryMapper orderHistories; + @Resource private RelabelApplicationMapper relabelApplications; + @Resource private RelabelItemMapper relabelItems; + @Resource private RelabelStatusHistoryMapper relabelHistories; + @Resource private LastlegOrderNoGenerator orderNoGenerator; + @Resource private InventoryCommandService inventory; + @Resource private InventoryCommandMapper inventoryCommands; + @Resource private ObjectMapper objectMapper; + + @Transactional + public WarehouseTransferResponses.Detail create(String customerId, String operatorId, + WarehouseTransferRequests.Create request) { + int itemPackages = request.items().stream().mapToInt(WarehouseTransferRequests.Item::packageCount).sum(); + if (itemPackages != request.packageCount()) { + throw badRequest("Cargo item package total must equal transfer package count"); + } + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + WarehouseTransferOrder replay = transfers.findKey(customerId, key); + if (replay != null) { + if (!requestHash.equals(replay.getRequestHash())) { + throw conflict("Idempotency key was reused with different warehouse transfer data"); + } + return detail(customerId, replay.getTransferNo()); + } + + String inboundCode = request.inboundCode().trim(); + Long inboundOrderId = inboundSnapshots.findId(customerId, inboundCode); + if (inboundOrderId == null) throw notFound("Inbound order was not found for inboundCode " + inboundCode); + List selectedIds = request.sourceLastlegOrderIds().stream().distinct().toList(); + List selectedOrders = orders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, selectedIds)); + if (selectedOrders.size() != selectedIds.size()) { + throw notFound("One or more selected customer orders do not exist in the current tenant"); + } + + LocalDateTime now = LocalDateTime.now(); + long transferId = IdWorker.getId(); + String transferNo = "WTR" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + transferId; + LastlegOrder order = createTailOrder(customerId, inboundOrderId, transferNo, request, requestHash, now); + + WarehouseTransferOrder transfer = new WarehouseTransferOrder(); + transfer.setId(transferId); transfer.setCustomerId(customerId); transfer.setTransferNo(transferNo); + transfer.setInboundOrderId(inboundOrderId); transfer.setInboundCode(inboundCode); + transfer.setLastlegOrderId(order.getId()); transfer.setLastlegOrderNo(order.getOrderNo()); + transfer.setOperatingWarehouseId(request.operatingWarehouseId().trim()); + transfer.setFbaWarehouseCode(request.fbaWarehouseCode().trim()); + transfer.setDeliveryAddress(request.deliveryAddress().trim()); transfer.setPackageCount(request.packageCount()); + transfer.setDeliveryMethod(request.deliveryMethod().trim()); transfer.setEstimatedArrivalAt(request.estimatedArrivalAt()); + transfer.setFbaShipmentNo(request.fbaShipmentNo().trim()); transfer.setTrackingNo(trim(request.trackingNo())); + transfer.setFbaBoxNos(trim(request.fbaBoxNos())); transfer.setRemark(trim(request.remark())); + transfer.setNeedRelabel(request.needRelabel()); transfer.setStatus(PENDING_REVIEW); + transfer.setIdempotencyKey(key); transfer.setRequestHash(requestHash); transfer.setCreatedBy(trim(operatorId)); + transfer.setCreatedAt(now); transfer.setUpdatedAt(now); + transfers.insert(transfer); + + for (LastlegOrder selectedOrder : selectedOrders) { + Long selectedInboundId = StringUtils.hasText(selectedOrder.getSourceInboundId()) + ? inboundSnapshots.findBySourceInboundId(customerId, selectedOrder.getSourceInboundId()) : null; + if (selectedInboundId == null) selectedInboundId = inboundSnapshots.findId(customerId, selectedOrder.getInboundCode()); + if (selectedInboundId == null) { + throw conflict("Selected order " + selectedOrder.getOrderNo() + " has no linked inbound order"); + } + WarehouseTransferSourceOrder link = new WarehouseTransferSourceOrder(); + link.setId(IdWorker.getId()); link.setTransferOrderId(transferId); link.setCustomerId(customerId); + link.setSourceLastlegOrderId(selectedOrder.getId()); link.setSourceLastlegOrderNo(selectedOrder.getOrderNo()); + link.setInboundOrderId(selectedInboundId); link.setInboundCode(selectedOrder.getInboundCode()); link.setCreatedAt(now); + sourceOrders.insert(link); + } + + for (int index = 0; index < request.items().size(); index++) { + WarehouseTransferRequests.Item source = request.items().get(index); + insertTransferItem(customerId, transferId, source, now); + insertOrderDetail(order.getId(), transferNo, index, source, request, now); + } + insertOrderHistory(order.getId(), null, PENDING_REVIEW, transferNo, + "Warehouse transfer submitted for review", now); + if (request.needRelabel()) createRelabel(customerId, operatorId, transfer, order, request, now); + return detail(customerId, transferNo); + } + + @Transactional(readOnly = true) + public WarehouseTransferResponses.Detail detail(String customerId, String transferNo) { + WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + List items = transferItems.selectList( + new LambdaQueryWrapper().eq(WarehouseTransferItem::getCustomerId, customerId) + .eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId)) + .stream().map(value -> new WarehouseTransferResponses.Item(value.getSkuCode(), value.getProductNameZh(), + value.getPackageCount(), value.getShippingMark())).toList(); + List sources = sourceOrders.selectList( + new LambdaQueryWrapper().eq(WarehouseTransferSourceOrder::getCustomerId, customerId) + .eq(WarehouseTransferSourceOrder::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferSourceOrder::getId)) + .stream().map(value -> new WarehouseTransferResponses.SourceOrder(value.getSourceLastlegOrderId(), + value.getSourceLastlegOrderNo(), value.getInboundOrderId(), value.getInboundCode())).toList(); + Map receiptByAllocation = transferReceipts.selectList( + new LambdaQueryWrapper().eq(WarehouseTransferReceipt::getCustomerId, customerId) + .eq(WarehouseTransferReceipt::getTransferOrderId, transfer.getId())) + .stream().collect(Collectors.toMap(WarehouseTransferReceipt::getAllocationId, Function.identity())); + List allocations = inventoryAllocations.selectList( + new LambdaQueryWrapper() + .eq(WarehouseTransferInventoryAllocation::getCustomerId, customerId) + .eq(WarehouseTransferInventoryAllocation::getTransferOrderId, transfer.getId()) + .orderByAsc(WarehouseTransferInventoryAllocation::getId)) + .stream().map(value -> { + WarehouseTransferReceipt receipt = receiptByAllocation.get(value.getId()); + return new WarehouseTransferResponses.Allocation(value.getInventoryPositionId(), value.getInboundOrderId(), + value.getInboundCode(), value.getPalletId(), value.getPalletNo(), value.getPackageCount(), + value.getStatus(), receipt == null ? null : receipt.getDestinationInventoryPositionId()); + }).toList(); + String relabelStatus = null; + if (transfer.getRelabelNo() != null) { + RelabelApplication relabel = relabelApplications.findNo(customerId, transfer.getRelabelNo()); + relabelStatus = relabel == null ? null : relabel.getStatus(); + } + LastlegOrder tailOrder = orders.selectById(transfer.getLastlegOrderId()); + return new WarehouseTransferResponses.Detail(transfer.getId(), transfer.getTransferNo(), transfer.getInboundOrderId(), + transfer.getInboundCode(), transfer.getLastlegOrderId(), transfer.getLastlegOrderNo(), + tailOrder == null ? ORDER_TYPE : tailOrder.getOrderType(), + tailOrder == null ? (transfer.getStatus() == null ? PENDING_REVIEW : transfer.getStatus()) : tailOrder.getCurrentStatus(), + Boolean.TRUE.equals(transfer.getNeedRelabel()), transfer.getRelabelApplicationId(), transfer.getRelabelNo(), + relabelStatus, + transfer.getOperatingWarehouseId(), transfer.getFbaWarehouseCode(), transfer.getDeliveryAddress(), + transfer.getPackageCount(), transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(), transfer.getFbaShipmentNo(), + transfer.getTrackingNo(), transfer.getFbaBoxNos(), transfer.getRemark(), sources, items, allocations, + transfer.getOutboundAt(), transfer.getCreatedAt()); + } + + @Transactional + public WarehouseTransferResponses.Detail review(String customerId, String transferNo, String operatorId, + WarehouseTransferRequests.Review request) { + String decision = request.decision().trim().toUpperCase(); + String next = switch (decision) { + case "APPROVE", APPROVED -> APPROVED; + case "REJECT", REJECTED -> REJECTED; + default -> throw badRequest("Review decision must be APPROVE or REJECT"); + }; + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (!PENDING_REVIEW.equals(transfer.getStatus())) { + if (next.equals(transfer.getStatus())) return detail(customerId, transferNo); + throw conflict("Warehouse transfer order was already reviewed"); + } + LastlegOrder order = orders.selectById(transfer.getLastlegOrderId()); + if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Linked tail-leg order is missing"); + LocalDateTime now = LocalDateTime.now(); + String before = order.getCurrentStatus(); + transfer.setStatus(next); transfer.setUpdatedAt(now); transfers.updateById(transfer); + String orderStatus = APPROVED.equals(next) ? PENDING_ASSIGNMENT : "CANCELLED"; + order.setCurrentStatus(orderStatus); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + next); + order.setUpdatedAt(now); orders.updateById(order); + if (!java.util.Objects.equals(before, orderStatus)) { + insertOrderHistory(order.getId(), before, orderStatus, transferNo, + "Warehouse transfer reviewed: " + next, now); + } + return detail(customerId, transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail allocate(String customerId, String transferNo, String operatorId, + WarehouseTransferRequests.Allocate request) { + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + if (ALLOCATED.equals(transfer.getStatus()) && key.equals(transfer.getAllocationIdempotencyKey())) { + if (!requestHash.equals(transfer.getAllocationRequestHash())) { + throw conflict("Allocation idempotency key was reused with different inventory positions"); + } + return detail(customerId, transferNo); + } + if (!APPROVED.equals(transfer.getStatus())) { + throw conflict("Only an approved warehouse transfer can allocate inventory"); + } + List requested = request.allocations().stream() + .sorted(java.util.Comparator.comparing(WarehouseTransferRequests.Allocation::inventoryPositionId)).toList(); + Set uniquePositions = new HashSet<>(); + long totalPackages = 0; + for (WarehouseTransferRequests.Allocation allocation : requested) { + if (!uniquePositions.add(allocation.inventoryPositionId())) { + throw badRequest("Inventory positions must be unique within a warehouse transfer"); + } + totalPackages = Math.addExact(totalPackages, allocation.packageCount()); + } + if (totalPackages != transfer.getPackageCount()) { + throw badRequest("Allocated package total must equal warehouse transfer package count"); + } + Set sourceInboundIds = sourceOrders.selectList(new LambdaQueryWrapper() + .eq(WarehouseTransferSourceOrder::getCustomerId, customerId) + .eq(WarehouseTransferSourceOrder::getTransferOrderId, transfer.getId())) + .stream().map(WarehouseTransferSourceOrder::getInboundOrderId).collect(Collectors.toSet()); + if (sourceInboundIds.isEmpty()) throw conflict("Warehouse transfer has no source inbound orders"); + + LocalDateTime now = LocalDateTime.now(); + for (WarehouseTransferRequests.Allocation source : requested) { + InventoryPosition position = inventoryCommands.lockPosition(customerId, source.inventoryPositionId()); + if (position == null) throw notFound("Inventory position not found: " + source.inventoryPositionId()); + InventoryBalance balance = inventoryCommands.lockBalance(customerId, position.getBalanceId()); + if (balance == null) throw conflict("Inventory position balance is missing"); + if (!source.palletId().equals(position.getPalletId())) { + throw conflict("Inventory position does not belong to pallet " + source.palletId()); + } + if (inventoryAllocations.countActiveDispatchLock(customerId, position.getPalletId()) > 0) { + throw conflict("Inventory pallet is already locked by an active dispatch"); + } + if (!sourceInboundIds.contains(balance.getInboundOrderId())) { + throw conflict("Inventory position is not linked to a selected source inbound order"); + } + if (packages(position.getAvailablePackages()) < source.packageCount()) { + throw conflict("Inventory position does not have enough available packages"); + } + WarehouseTransferInventoryAllocation allocation = new WarehouseTransferInventoryAllocation(); + allocation.setId(IdWorker.getId()); allocation.setTransferOrderId(transfer.getId()); + allocation.setCustomerId(customerId); allocation.setInventoryPositionId(position.getId()); + allocation.setInventoryBalanceId(balance.getId()); allocation.setInboundOrderId(balance.getInboundOrderId()); + allocation.setInboundCode(balance.getInboundCode()); allocation.setPalletId(position.getPalletId()); + allocation.setPalletNo(position.getPalletNo()); allocation.setPackageCount(source.packageCount()); + allocation.setStatus("RESERVED"); allocation.setActivePositionKey(customerId + ":" + position.getId()); + allocation.setAllocatedAt(now); allocation.setCreatedAt(now); allocation.setUpdatedAt(now); + try { + inventoryAllocations.insert(allocation); + } catch (DuplicateKeyException exception) { + throw conflict("Inventory position is already reserved by another warehouse transfer"); + } + } + transfer.setStatus(ALLOCATED); transfer.setAllocationIdempotencyKey(key); + transfer.setAllocationRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer); + LastlegOrder order = requireTailOrder(customerId, transfer); + String before = order.getCurrentStatus(); order.setCurrentStatus("WAIT_OUTBOUND"); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:ALLOCATED"); order.setUpdatedAt(now); + orders.updateById(order); + insertOrderHistory(order.getId(), before, order.getCurrentStatus(), transferNo, + "Warehouse transfer inventory allocated", now); + return detail(customerId, transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail outbound(String customerId, String transferNo, String operatorId, + WarehouseTransferRequests.Outbound request) { + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + if (Set.of(OUTBOUNDED, PARTIALLY_RECEIVED, RECEIVED).contains(transfer.getStatus()) + && key.equals(transfer.getOutboundIdempotencyKey())) { + if (!requestHash.equals(transfer.getOutboundRequestHash())) { + throw conflict("Outbound idempotency key was reused with different data"); + } + return detail(customerId, transferNo); + } + if (!ALLOCATED.equals(transfer.getStatus())) { + throw conflict("Warehouse transfer inventory must be allocated before outbound"); + } + List allocations = inventoryAllocations.lockTransfer(customerId, transfer.getId()); + if (allocations.isEmpty() || allocations.stream().mapToLong(WarehouseTransferInventoryAllocation::getPackageCount).sum() + != transfer.getPackageCount()) { + throw conflict("Warehouse transfer inventory allocation is incomplete"); + } + LocalDateTime now = LocalDateTime.now(); + for (WarehouseTransferInventoryAllocation allocation : allocations) { + if (!"RESERVED".equals(allocation.getStatus())) throw conflict("Warehouse transfer allocation was already processed"); + String keyBase = "WAREHOUSE_TRANSFER:" + transferNo + ":" + allocation.getInventoryPositionId(); + InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(customerId, + allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo, + "WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":ALLOCATE", trim(request.remark())); + inventory.allocate(customerId, operatorId, move); + inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(customerId, + allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo, + "WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":PICK", trim(request.remark()))); + inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(customerId, + allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo, + "WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":SHIP", trim(request.remark()))); + allocation.setStatus("SHIPPED"); allocation.setActivePositionKey(null); + allocation.setShippedAt(now); allocation.setUpdatedAt(now); inventoryAllocations.updateById(allocation); + } + transfer.setStatus(OUTBOUNDED); transfer.setOutboundIdempotencyKey(key); + transfer.setOutboundRequestHash(requestHash); transfer.setOutboundAt(now); transfer.setUpdatedAt(now); + transfers.updateById(transfer); + LastlegOrder order = requireTailOrder(customerId, transfer); + String before = order.getCurrentStatus(); order.setCurrentStatus("IN_TRANSIT"); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:OUTBOUNDED"); order.setUpdatedAt(now); + orders.updateById(order); + insertOrderHistory(order.getId(), before, order.getCurrentStatus(), transferNo, + "Warehouse transfer left source warehouse", now); + return detail(customerId, transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail receive(String customerId, String transferNo, String operatorId, + WarehouseTransferRequests.Receipt request) { + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (!Set.of(OUTBOUNDED, PARTIALLY_RECEIVED).contains(transfer.getStatus())) { + if (RECEIVED.equals(transfer.getStatus())) { + WarehouseTransferReceipt replay = transferReceipts.findSource(customerId, transfer.getId(), + request.sourceInventoryPositionId()); + if (replay != null && replay.getRequestHash().equals(hash(request))) return detail(customerId, transferNo); + } + throw conflict("Only outbound warehouse transfer inventory can be received"); + } + List allocations = inventoryAllocations.lockTransfer(customerId, transfer.getId()); + WarehouseTransferInventoryAllocation allocation = allocations.stream() + .filter(value -> request.sourceInventoryPositionId().equals(value.getInventoryPositionId())) + .findFirst().orElseThrow(() -> notFound("Source inventory position is not allocated to this transfer")); + String requestHash = hash(request); + WarehouseTransferReceipt existing = transferReceipts.findSource(customerId, transfer.getId(), + request.sourceInventoryPositionId()); + if (existing != null) { + if (!requestHash.equals(existing.getRequestHash())) { + throw conflict("Transfer source position was already received with different destination data"); + } + return detail(customerId, transferNo); + } + if (!"SHIPPED".equals(allocation.getStatus())) throw conflict("Transfer allocation is not awaiting receipt"); + + String operationKey = "WAREHOUSE_TRANSFER_RECEIPT:" + transferNo + ":" + allocation.getInventoryPositionId(); + 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, + request.destinationPalletId(), request.destinationPalletNo().trim(), + trim(request.batchNo()) == null ? transferNo : request.batchNo().trim(), request.warehouseLocationId(), + trim(request.warehouseLocationCode()), allocation.getPackageCount(), transferNo, + "WAREHOUSE_TRANSFER_RECEIPT:" + transferNo, operationKey, trim(request.remark()))); + LocalDateTime now = LocalDateTime.now(); + WarehouseTransferReceipt receipt = new WarehouseTransferReceipt(); + receipt.setId(IdWorker.getId()); receipt.setTransferOrderId(transfer.getId()); receipt.setAllocationId(allocation.getId()); + receipt.setCustomerId(customerId); receipt.setSourceInventoryPositionId(allocation.getInventoryPositionId()); + receipt.setDestinationInventoryBalanceId(result.balanceId()); receipt.setDestinationInventoryPositionId(result.positionId()); + receipt.setPackageCount(allocation.getPackageCount()); receipt.setIdempotencyKey(request.idempotencyKey().trim()); + receipt.setRequestHash(requestHash); receipt.setInventoryOperationKey(operationKey); receipt.setCreatedBy(trim(operatorId)); + receipt.setReceivedAt(now); receipt.setCreatedAt(now); transferReceipts.insert(receipt); + allocation.setStatus("RECEIVED"); allocation.setReceivedAt(now); allocation.setUpdatedAt(now); + inventoryAllocations.updateById(allocation); + + boolean complete = allocations.stream().allMatch(value -> value.getId().equals(allocation.getId()) + || "RECEIVED".equals(value.getStatus())); + transfer.setStatus(complete ? RECEIVED : PARTIALLY_RECEIVED); transfer.setUpdatedAt(now); transfers.updateById(transfer); + LastlegOrder order = requireTailOrder(customerId, transfer); + String before = order.getCurrentStatus(); order.setCurrentStatus(complete ? "COMPLETED" : "IN_TRANSIT"); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + (complete ? RECEIVED : PARTIALLY_RECEIVED)); + order.setUpdatedAt(now); orders.updateById(order); + insertOrderHistory(order.getId(), before, order.getCurrentStatus(), transferNo, + "Warehouse transfer inventory received at destination", now); + return detail(customerId, transferNo); + } + + private LastlegOrder createTailOrder(String customerId, Long inboundOrderId, String transferNo, + WarehouseTransferRequests.Create request, String requestHash, LocalDateTime now) { + LastlegOrder order = new LastlegOrder(); + order.setId(IdWorker.getId()); order.setOrderNo(orderNoGenerator.next()); order.setCustomerId(customerId); + order.setOrderType(request.needRelabel() ? RETURN_RELABEL_ORDER_TYPE : ORDER_TYPE); + order.setInboundCode(request.inboundCode().trim()); order.setSourceRegion("CUSTOMER"); + order.setSourceInboundId("WAREHOUSE_TRANSFER:" + transferNo); order.setRootInboundId(String.valueOf(inboundOrderId)); + order.setSourceOrderNo(transferNo); order.setOverseasWarehouseId(request.operatingWarehouseId().trim()); + order.setFbaWarehouseCode(request.fbaWarehouseCode().trim()); order.setPackageCount(request.packageCount()); + order.setVolumeCbm(request.volumeCbm()); + order.setContainerNo(transferNo); + order.setPalletCount(0); order.setPickedPackageCount(0); order.setShippedPackageCount(0); + order.setEstimatedWarehouseAt(request.estimatedArrivalAt()); + order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW); + order.setCustomerRemark(trim(request.remark())); + order.setSourceEventId("WAREHOUSE_TRANSFER:" + transferNo); order.setSourceLoadingPlanId(ORDER_TYPE); + order.setSourcePayloadHash("sha256:" + requestHash); order.setCreatedAt(now); order.setUpdatedAt(now); + orders.insert(order); + return order; + } + + private void insertTransferItem(String customerId, long transferId, WarehouseTransferRequests.Item source, + LocalDateTime now) { + WarehouseTransferItem item = new WarehouseTransferItem(); + item.setId(IdWorker.getId()); item.setTransferOrderId(transferId); item.setCustomerId(customerId); + item.setSkuCode(source.skuCode().trim()); item.setProductNameZh(source.productNameZh().trim()); + item.setPackageCount(source.packageCount()); item.setShippingMark(trim(source.shippingMark())); item.setCreatedAt(now); + transferItems.insert(item); + } + + private void insertOrderDetail(long orderId, String transferNo, int index, WarehouseTransferRequests.Item source, + WarehouseTransferRequests.Create request, LocalDateTime now) { + LastlegOrderDetail detail = new LastlegOrderDetail(); + detail.setOrderId(orderId); detail.setSourceInboundDetailId(transferNo + ":" + index); + detail.setSkuCode(source.skuCode().trim()); detail.setProductNameZh(source.productNameZh().trim()); + detail.setPackageCount(source.packageCount()); detail.setDestinationWarehouseCode(request.fbaWarehouseCode().trim()); + detail.setFbaTrackingNo(trim(request.trackingNo())); detail.setShipmentNo(request.fbaShipmentNo().trim()); + detail.setShippingMark(trim(source.shippingMark())); orderDetails.insert(detail); + } + + private void createRelabel(String customerId, String operatorId, WarehouseTransferOrder transfer, LastlegOrder order, + WarehouseTransferRequests.Create request, LocalDateTime now) { + long applicationId = IdWorker.getId(); + String relabelNo = "FBR" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + applicationId; + RelabelApplication application = new RelabelApplication(); + application.setId(applicationId); application.setCustomerId(customerId); application.setRelabelNo(relabelNo); + application.setLastlegOrderId(order.getId()); application.setCustomerOrderNo(transfer.getTransferNo()); + application.setOverseasWarehouseId(request.operatingWarehouseId().trim()); application.setLabelDeadline(request.estimatedArrivalAt()); + application.setContactName(StringUtils.hasText(operatorId) ? operatorId.trim() : "SYSTEM"); + application.setContactPhone("N/A"); application.setRemark(trim(request.remark())); + application.setStatus(RelabelApplicationService.PENDING_PROCESSING); application.setItemCount(request.items().size()); + application.setTotalPackageCount(request.packageCount()); application.setNotifiedPackageCount(request.packageCount()); + application.setIdempotencyKey("WAREHOUSE_TRANSFER:" + request.idempotencyKey().trim()); + application.setRequestHash(hash(request)); application.setCreatedBy(trim(operatorId)); + application.setCreatedAt(now); application.setUpdatedAt(now); relabelApplications.insert(application); + + for (WarehouseTransferRequests.Item source : request.items()) { + RelabelItem item = new RelabelItem(); + item.setId(IdWorker.getId()); item.setApplicationId(applicationId); item.setCustomerId(customerId); + item.setSkuCode(source.skuCode().trim()); item.setProductNameZh(source.productNameZh().trim()); + item.setTotalPackageCount(source.packageCount()); item.setNotifiedPackageCount(source.packageCount()); + item.setShipmentNo(request.fbaShipmentNo().trim()); item.setTrackingCode(trim(request.trackingNo())); + item.setNewSkuCode(source.skuCode().trim()); item.setLabelRequirement("WAREHOUSE_TRANSFER_RELABEL"); + item.setCreatedAt(now); relabelItems.insert(item); + } + RelabelStatusHistory history = new RelabelStatusHistory(); + history.setId(IdWorker.getId()); history.setCustomerId(customerId); history.setApplicationId(applicationId); + history.setToStatus(RelabelApplicationService.PENDING_PROCESSING); history.setOperatorId(trim(operatorId)); + history.setRemark("Created from warehouse transfer " + transfer.getTransferNo()); history.setCreatedAt(now); + relabelHistories.insert(history); + transfer.setRelabelApplicationId(applicationId); transfer.setRelabelNo(relabelNo); transfer.setUpdatedAt(now); + transfers.updateById(transfer); + order.setRelabelOrderNo(relabelNo); order.setRelabelStatus(RelabelApplicationService.PENDING_PROCESSING); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW + ";RELABEL:" + RelabelApplicationService.PENDING_PROCESSING); + order.setUpdatedAt(now); orders.updateById(order); + } + + private void insertOrderHistory(long orderId, String fromStatus, String toStatus, String transferNo, + String remark, LocalDateTime now) { + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); + history.setOrderId(orderId); history.setFromStatus(fromStatus); history.setToStatus(toStatus); + history.setEventId("WAREHOUSE_TRANSFER:" + transferNo); history.setRemark(remark); + history.setCreatedAt(now); orderHistories.insert(history); + } + + private LastlegOrder requireTailOrder(String customerId, WarehouseTransferOrder transfer) { + LastlegOrder order = orders.selectById(transfer.getLastlegOrderId()); + if (order == null || !customerId.equals(order.getCustomerId())) { + throw conflict("Linked tail-leg order is missing"); + } + return order; + } + + private String hash(Object value) { + try { + byte[] json = objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json)); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static long packages(Long value) { return value == null ? 0L : value; } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + 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/trucking/TruckingMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/TruckingMapperConfiguration.java new file mode 100644 index 000000000..4b9ecc170 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/TruckingMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.trucking; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.trucking.mapper") +public class TruckingMapperConfiguration { +} 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 new file mode 100644 index 000000000..2c30b5907 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java @@ -0,0 +1,62 @@ +package com.baiyue.lastleg.trucking.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.trucking.dto.UsTruckOrderRequests; +import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses; +import com.baiyue.lastleg.trucking.service.UsTruckOrderService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/merchant/v1/last-leg/us-truck-orders") +public class MerchantUsTruckOrderController { + @Resource private UsTruckOrderService service; + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody UsTruckOrderRequests.Create request) { + return ApiResponse.success(service.create(customer(user), operator(user), request)); + } + + @GetMapping + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid UsTruckOrderRequests.Query query) { + return ApiResponse.success(service.page(customer(user), query)); + } + + @GetMapping("/{orderNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String orderNo) { + return ApiResponse.success(service.detail(customer(user), orderNo)); + } + + @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)); + } + + private String customer(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required"); + } + return String.valueOf(user.tenantId()); + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} 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 new file mode 100644 index 000000000..3dba2f252 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java @@ -0,0 +1,83 @@ +package com.baiyue.lastleg.trucking.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.trucking.dto.UsTruckOrderRequests; +import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses; +import com.baiyue.lastleg.trucking.service.UsTruckOrderService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/us-truck-orders") +public class PlatformUsTruckOrderController { + @Resource private UsTruckOrderService service; + + @GetMapping + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid UsTruckOrderRequests.Query query) { + requirePlatform(user); + return ApiResponse.success(service.page(null, query)); + } + + @GetMapping("/{orderNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String orderNo) { + requirePlatform(user); + return ApiResponse.success(service.detail(requiredCustomer(customerId), orderNo)); + } + + @PatchMapping("/{orderNo}/status") + public ApiResponse changeStatus(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String orderNo, + @Valid @RequestBody UsTruckOrderRequests.ChangeStatus request) { + requirePermission(user); + return ApiResponse.success(service.changeStatus(requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PostMapping("/{orderNo}/receive-pallets") + public ApiResponse receivePallets(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String orderNo, + @Valid @RequestBody UsTruckOrderRequests.ReceivePallets request) { + requirePermission(user); + return ApiResponse.success(service.receivePallets(requiredCustomer(customerId), orderNo, operator(user), request)); + } + + private void requirePlatform(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required"); + } + } + + private void requirePermission(AuthenticatedUser user) { + requirePlatform(user); + if (user.permissions() == null || !user.permissions().contains(UsTruckOrderService.MANAGE_PERMISSION)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + UsTruckOrderService.MANAGE_PERMISSION + " permission is required"); + } + } + + private String requiredCustomer(String customerId) { + if (customerId == null || customerId.isBlank() || "0".equals(customerId.trim())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Target customerId is required"); + } + return customerId.trim(); + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java new file mode 100644 index 000000000..120364ad3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java @@ -0,0 +1,89 @@ +package com.baiyue.lastleg.trucking.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +public final class UsTruckOrderRequests { + private UsTruckOrderRequests() {} + + public record Create( + @Size(max = 32) String transportType, + @NotBlank @Size(max = 255) String origin, + @Size(max = 500) String originAddress, + @NotBlank @Size(max = 255) String destination, + @Size(max = 500) String destinationAddress, + @Size(max = 32) String containerType, + @NotNull @Min(1) Integer palletCount, + @Min(0) Integer packageCount, + @NotNull @DecimalMin("0.00") BigDecimal totalWeightKg, + @NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm, + @DecimalMin("0.00") BigDecimal budgetAmount, + @Size(min = 3, max = 3) String currency, + @NotBlank @Size(max = 128) String contactName, + @NotBlank @Size(max = 128) String contactInfo, + @NotNull LocalDate requestedDate, + @Size(max = 1000) String remark, + @NotBlank @Size(max = 255) String idempotencyKey) { + /** Backward-compatible constructor for clients still sending legacy transport fields. */ + public Create(String transportType, String origin, String originAddress, String destination, + String destinationAddress, String containerType, Integer packageCount, + BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, + String currency, String contactName, String contactInfo, LocalDate requestedDate, + String remark, String idempotencyKey) { + this(transportType, origin, originAddress, destination, destinationAddress, containerType, + packageCount == null || packageCount < 1 ? 1 : packageCount, packageCount, + totalWeightKg, totalVolumeCbm, budgetAmount, currency, contactName, contactInfo, + requestedDate, remark, idempotencyKey); + } + } + + public record Query( + @Min(1) long page, @Min(1) @Max(200) long size, + String orderNo, String routeKeyword, String status, String transportType, + String customerId, LocalDate requestedFrom, LocalDate requestedTo) {} + + public record Update( + @NotNull @Min(0) Long version, + @Size(max = 32) String transportType, + @Size(max = 255) String origin, + @Size(max = 500) String originAddress, + @Size(max = 255) String destination, + @Size(max = 500) String destinationAddress, + @Size(max = 32) String containerType, + @Min(1) Integer palletCount, + @Min(0) Integer packageCount, + @DecimalMin("0.00") BigDecimal totalWeightKg, + @DecimalMin("0.00") BigDecimal totalVolumeCbm, + @DecimalMin("0.00") BigDecimal budgetAmount, + @Size(min = 3, max = 3) String currency, + @Size(max = 128) String contactName, + @Size(max = 128) String contactInfo, + LocalDate requestedDate, + @Size(max = 1000) String remark) {} + + public record ChangeStatus( + @NotNull @Min(0) Long version, + @NotBlank @Size(max = 32) String status, + @Size(max = 1000) String remark) {} + + public record ReceivePallets( + @NotNull @Min(0) Long version, + @NotNull @Min(1) Integer actualPalletCount, + @Min(0) Integer packageCount, + @Size(max = 500) List<@Min(0) Integer> palletPackageCounts, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) { + public ReceivePallets(Long version, Integer actualPalletCount, Integer packageCount, + String idempotencyKey, String remark) { + this(version, actualPalletCount, packageCount, List.of(), idempotencyKey, remark); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java new file mode 100644 index 000000000..6169954b8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.trucking.dto; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public final class UsTruckOrderResponses { + private UsTruckOrderResponses() {} + + public record Summary( + Long id, String customerId, String orderNo, String transportType, + String origin, String destination, String containerType, int palletCount, int packageCount, + int receivedPalletCount, int allocatedPalletCount, int shippedPalletCount, + BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, + String currency, String contactName, String contactInfo, LocalDate requestedDate, + String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {} + + public record History( + String fromStatus, String toStatus, String operatorId, String remark, LocalDateTime createdAt) {} + + public record Pallet(Long id, String palletNo, int sequenceNo, String source, String status, + Long warehousePalletId, Integer packageCount, LocalDateTime receivedAt) {} + + public record Detail( + Summary summary, String originAddress, String destinationAddress, String remark, + String createdBy, List pallets, List histories) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java new file mode 100644 index 000000000..264ab0905 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.trucking.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_us_truck_order") +public class UsTruckOrder { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private String orderNo; + private String transportType; + private String origin; + private String originAddress; + private String destination; + private String destinationAddress; + private String containerType; + private Integer palletCount; + private Integer packageCount; + private Integer receivedPalletCount; + private Integer allocatedPalletCount; + private Integer shippedPalletCount; + private BigDecimal totalWeightKg; + private BigDecimal totalVolumeCbm; + private BigDecimal budgetAmount; + private String currency; + private String contactName; + private String contactInfo; + private LocalDate requestedDate; + private String status; + private String remark; + private String idempotencyKey; + private String requestHash; + private String receiptIdempotencyKey; + private String receiptRequestHash; + private String createdBy; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderPallet.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderPallet.java new file mode 100644 index 000000000..593e7047b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderPallet.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.trucking.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_us_truck_order_pallet") +public class UsTruckOrderPallet { + @TableId(type = IdType.INPUT) private Long id; + private Long orderId; + private String customerId; + private String palletNo; + private Integer sequenceNo; + private String source; + private String status; + private Long warehousePalletId; + private Integer packageCount; + private LocalDateTime receivedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java new file mode 100644 index 000000000..be4fee492 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.trucking.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_us_truck_order_status_history") +public class UsTruckOrderStatusHistory { + @TableId(type = IdType.INPUT) private Long id; + private Long orderId; + private String customerId; + private String fromStatus; + private String toStatus; + private String operatorId; + private String remark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java new file mode 100644 index 000000000..f6c43085d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.trucking.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.trucking.entity.UsTruckOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface UsTruckOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_us_truck_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1") + UsTruckOrder findNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo); + + @Select("SELECT * FROM ll_us_truck_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + UsTruckOrder findKey(@Param("customerId") String customerId, @Param("key") String key); + + @Select("SELECT * FROM ll_us_truck_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1 FOR UPDATE") + UsTruckOrder lockNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderPalletMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderPalletMapper.java new file mode 100644 index 000000000..3eecc0897 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderPalletMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.trucking.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet; +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 UsTruckOrderPalletMapper extends BaseMapper { + @Select("SELECT * FROM ll_us_truck_order_pallet WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY sequence_no") + List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderStatusHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderStatusHistoryMapper.java new file mode 100644 index 000000000..8875ec5b1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderStatusHistoryMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.trucking.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory; +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 UsTruckOrderStatusHistoryMapper extends BaseMapper { + @Select("SELECT * FROM ll_us_truck_order_status_history WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY created_at,id") + List listOrder(@Param("customerId") String customerId, + @Param("orderId") Long orderId); +} 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 new file mode 100644 index 000000000..4c6453f2d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java @@ -0,0 +1,278 @@ +package com.baiyue.lastleg.trucking.service; + +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.trucking.dto.UsTruckOrderRequests; +import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses; +import com.baiyue.lastleg.trucking.entity.UsTruckOrder; +import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet; +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.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; + +@Service +public class UsTruckOrderService { + public static final String MANAGE_PERMISSION = "lastleg:us-trucking:manage"; + 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; + public static final String WAITING_INBOUND = "WAITING_INBOUND"; + public static final String RECEIVING = "RECEIVING"; + public static final String WAITING_CONSOLIDATION = "WAITING_CONSOLIDATION"; + public static final String PALLET_OVERAGE_REVIEW = "PALLET_OVERAGE_REVIEW"; + public static final String PARTIALLY_ALLOCATED = "PARTIALLY_ALLOCATED"; + public static final String ALLOCATED = "ALLOCATED"; + public static final String PENDING_DISPATCH = "PENDING_DISPATCH"; + public static final String DISPATCHED = "DISPATCHED"; + public static final String IN_TRANSIT = "IN_TRANSIT"; + public static final String COMPLETED = "COMPLETED"; + public static final String REJECTED = "REJECTED"; + public static final String CANCELLED = "CANCELLED"; + private static final Set STATUSES = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING, + WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED, + PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED); + private static final java.util.Map> TRANSITIONS = java.util.Map.ofEntries( + java.util.Map.entry(PENDING_REVIEW, Set.of(WAITING_INBOUND, REJECTED, CANCELLED)), + java.util.Map.entry(WAITING_INBOUND, Set.of(RECEIVING, CANCELLED)), + java.util.Map.entry(RECEIVING, Set.of(WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, CANCELLED)), + java.util.Map.entry(PALLET_OVERAGE_REVIEW, Set.of(WAITING_CONSOLIDATION, CANCELLED)), + java.util.Map.entry(WAITING_CONSOLIDATION, Set.of(PARTIALLY_ALLOCATED, ALLOCATED, CANCELLED)), + java.util.Map.entry(PARTIALLY_ALLOCATED, Set.of(ALLOCATED, CANCELLED)), + java.util.Map.entry(ALLOCATED, Set.of(PENDING_DISPATCH, CANCELLED)), + java.util.Map.entry(PENDING_DISPATCH, Set.of(DISPATCHED, CANCELLED)), + java.util.Map.entry(DISPATCHED, Set.of(IN_TRANSIT)), + java.util.Map.entry(IN_TRANSIT, Set.of(COMPLETED))); + private static final Set TRANSPORT_TYPES = Set.of("PORT_PICKUP", "WAREHOUSE_TO_WAREHOUSE", "WAREHOUSE_TO_DOOR"); + + @Resource private UsTruckOrderMapper orders; + @Resource private UsTruckOrderPalletMapper pallets; + @Resource private UsTruckOrderStatusHistoryMapper histories; + @Resource private ObjectMapper objectMapper; + + @Transactional + public UsTruckOrderResponses.Detail create(String customerId, String operatorId, UsTruckOrderRequests.Create request) { + String key = request.idempotencyKey().trim(); + String hash = hash(request); + UsTruckOrder replay = orders.findKey(customerId, key); + if (replay != null) return replay(customerId, replay, hash); + if (StringUtils.hasText(request.transportType())) validateTransport(request.transportType()); + LocalDateTime now = LocalDateTime.now(); + UsTruckOrder order = new UsTruckOrder(); + order.setId(IdWorker.getId()); + order.setCustomerId(customerId); + order.setOrderNo("TK" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + order.getId()); + order.setTransportType(StringUtils.hasText(request.transportType()) ? norm(request.transportType()) : null); + order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress())); + order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress())); + order.setContainerType(StringUtils.hasText(request.containerType()) ? norm(request.containerType()) : null); + order.setPalletCount(request.palletCount()); order.setPackageCount(value(request.packageCount())); + order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setShippedPalletCount(0); + order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm()); + order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency())); + order.setContactName(request.contactName().trim()); order.setContactInfo(request.contactInfo().trim()); + order.setRequestedDate(request.requestedDate()); order.setStatus(PENDING_REVIEW); + order.setRemark(trim(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(hash); + order.setCreatedBy(operatorId); order.setVersion(0L); order.setCreatedAt(now); order.setUpdatedAt(now); + try { orders.insert(order); } + catch (DuplicateKeyException e) { + UsTruckOrder concurrent = orders.findKey(customerId, key); + if (concurrent != null) return replay(customerId, concurrent, hash); + throw e; + } + history(order, null, PENDING_REVIEW, operatorId, request.remark(), now); + return detail(customerId, order.getOrderNo()); + } + + public PageResult page(String customerId, UsTruckOrderRequests.Query query) { + validateQuery(query); + LambdaQueryWrapper w = new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), UsTruckOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.orderNo()), UsTruckOrder::getOrderNo, query.orderNo()) + .and(StringUtils.hasText(query.routeKeyword()), x -> x.like(UsTruckOrder::getOrigin, query.routeKeyword()) + .or().like(UsTruckOrder::getDestination, query.routeKeyword())) + .eq(StringUtils.hasText(query.status()), UsTruckOrder::getStatus, + StringUtils.hasText(query.status()) ? norm(query.status()) : null) + .eq(StringUtils.hasText(query.transportType()), UsTruckOrder::getTransportType, + StringUtils.hasText(query.transportType()) ? norm(query.transportType()) : null) + .eq(StringUtils.hasText(query.customerId()), UsTruckOrder::getCustomerId, query.customerId()) + .ge(query.requestedFrom() != null, UsTruckOrder::getRequestedDate, query.requestedFrom()) + .le(query.requestedTo() != null, UsTruckOrder::getRequestedDate, query.requestedTo()) + .orderByDesc(UsTruckOrder::getCreatedAt).orderByDesc(UsTruckOrder::getId); + Page result = orders.selectPage(Page.of(query.page(), query.size()), w); + return new PageResult<>(result.getRecords().stream().map(this::summary).toList(), result.getCurrent(), + result.getSize(), result.getTotal(), result.getPages()); + } + + public UsTruckOrderResponses.Detail detail(String customerId, String orderNo) { + UsTruckOrder order = orders.findNo(customerId, orderNo); + if (order == null) throw notFound(); + return detail(order, histories.listOrder(customerId, order.getId())); + } + + @Transactional + public UsTruckOrderResponses.Detail update(String customerId, String orderNo, 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"); + if (request.transportType() != null) { validateTransport(request.transportType()); order.setTransportType(norm(request.transportType())); } + setText(request.origin(), order::setOrigin); setPresent(request.originAddress(), order::setOriginAddress); + setText(request.destination(), order::setDestination); setPresent(request.destinationAddress(), order::setDestinationAddress); + setText(request.containerType(), v -> order.setContainerType(norm(v))); + if (request.palletCount() != null) order.setPalletCount(request.palletCount()); + if (request.packageCount() != null) order.setPackageCount(request.packageCount()); + if (request.totalWeightKg() != null) order.setTotalWeightKg(request.totalWeightKg()); if (request.totalVolumeCbm() != null) order.setTotalVolumeCbm(request.totalVolumeCbm()); + 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); + } + + @Transactional + public UsTruckOrderResponses.Detail changeStatus(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.ChangeStatus request) { + UsTruckOrder order = lock(customerId, orderNo); + if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); + String next = norm(request.status()); if (!STATUSES.contains(next)) throw badRequest("Unsupported truck order status"); + String from = order.getStatus(); + if (!next.equals(from)) { + if (!TRANSITIONS.getOrDefault(from, Set.of()).contains(next)) { + throw conflict("Unsupported truck order transition: " + from + " -> " + next); + } + order.setStatus(next); + if (PENDING_REVIEW.equals(from) && WAITING_INBOUND.equals(next)) createPlannedPallets(order); + touch(order); orders.updateById(order); + history(order, from, next, operatorId, request.remark(), LocalDateTime.now()); + } + return detail(customerId, orderNo); + } + + @Transactional + public UsTruckOrderResponses.Detail receivePallets(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.ReceivePallets request) { + UsTruckOrder order = lock(customerId, orderNo); + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + if (key.equals(order.getReceiptIdempotencyKey())) { + if (!requestHash.equals(order.getReceiptRequestHash())) { + throw conflict("Receipt idempotency key was reused with different pallet data"); + } + return detail(customerId, orderNo); + } + if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); + if (!Set.of(WAITING_INBOUND, RECEIVING).contains(order.getStatus())) { + throw conflict("Only waiting or receiving orders can receive pallets"); + } + + createPlannedPallets(order); + List current = pallets.listOrder(customerId, order.getId()); + int planned = value(order.getPalletCount()); + int actual = request.actualPalletCount(); + List packageCounts = request.palletPackageCounts() == null ? List.of() + : request.palletPackageCounts(); + if (!packageCounts.isEmpty() && packageCounts.size() != actual) { + throw badRequest("palletPackageCounts must be empty or contain one value per actual pallet"); + } + if (!packageCounts.isEmpty() && request.packageCount() != null + && packageCounts.stream().mapToInt(Integer::intValue).sum() != request.packageCount()) { + throw badRequest("Per-pallet package counts must equal the received package total"); + } + LocalDateTime now = LocalDateTime.now(); + for (int sequence = 1; sequence <= actual; sequence++) { + UsTruckOrderPallet pallet = sequence <= current.size() ? current.get(sequence - 1) + : newPallet(order, sequence, "PDA_OVERAGE", now); + pallet.setStatus(sequence > planned ? "OVERAGE_PENDING_REVIEW" : "RECEIVED"); + if (!packageCounts.isEmpty()) pallet.setPackageCount(packageCounts.get(sequence - 1)); + else if (actual == 1) pallet.setPackageCount(request.packageCount()); + pallet.setReceivedAt(now); + pallet.setUpdatedAt(now); + if (pallet.getId() == null) { + pallet.setId(IdWorker.getId()); + pallets.insert(pallet); + } else { + pallets.updateById(pallet); + } + } + order.setReceivedPalletCount(actual); + order.setReceiptIdempotencyKey(key); + order.setReceiptRequestHash(requestHash); + String from = order.getStatus(); + String next = actual > planned ? PALLET_OVERAGE_REVIEW + : actual == planned ? WAITING_CONSOLIDATION : RECEIVING; + order.setStatus(next); + touch(order); + orders.updateById(order); + history(order, from, next, operatorId, request.remark(), now); + return detail(customerId, orderNo); + } + + private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); return o; } + private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); } + private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List hs) { + List palletDetails = pallets.listOrder(o.getCustomerId(), o.getId()).stream() + .map(this::pallet) + .toList(); + return new UsTruckOrderResponses.Detail(summary(o), o.getOriginAddress(), o.getDestinationAddress(), + o.getRemark(), o.getCreatedBy(), palletDetails, + hs.stream().map(h -> new UsTruckOrderResponses.History(h.getFromStatus(), h.getToStatus(), + h.getOperatorId(), h.getRemark(), h.getCreatedAt())).toList()); + } + private UsTruckOrderResponses.Pallet pallet(UsTruckOrderPallet p) { + return new UsTruckOrderResponses.Pallet(p.getId(), p.getPalletNo(), value(p.getSequenceNo()), + p.getSource(), p.getStatus(), p.getWarehousePalletId(), p.getPackageCount(), p.getReceivedAt()); + } + private UsTruckOrderResponses.Summary summary(UsTruckOrder o) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getTransportType(), o.getOrigin(), o.getDestination(), o.getContainerType(), value(o.getPalletCount()), value(o.getPackageCount()), value(o.getReceivedPalletCount()), value(o.getAllocatedPalletCount()), value(o.getShippedPalletCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getContactName(), o.getContactInfo(), o.getRequestedDate(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt()); } + private void history(UsTruckOrder o, String from, String to, String operator, String remark, LocalDateTime at) { UsTruckOrderStatusHistory h = new UsTruckOrderStatusHistory(); h.setId(IdWorker.getId()); h.setOrderId(o.getId()); h.setCustomerId(o.getCustomerId()); h.setFromStatus(from); h.setToStatus(to); h.setOperatorId(operator); h.setRemark(trim(remark)); h.setCreatedAt(at); histories.insert(h); } + private void createPlannedPallets(UsTruckOrder order) { + int existing = pallets.listOrder(order.getCustomerId(), order.getId()).size(); + LocalDateTime now = LocalDateTime.now(); + for (int sequence = existing + 1; sequence <= value(order.getPalletCount()); sequence++) { + UsTruckOrderPallet pallet = newPallet(order, sequence, "ORDER_PLAN", now); + pallet.setId(IdWorker.getId()); + pallets.insert(pallet); + } + } + private UsTruckOrderPallet newPallet(UsTruckOrder order, int sequence, String source, LocalDateTime now) { + UsTruckOrderPallet pallet = new UsTruckOrderPallet(); + pallet.setOrderId(order.getId()); pallet.setCustomerId(order.getCustomerId()); + pallet.setPalletNo(order.getOrderNo() + "-P" + String.format("%03d", sequence)); + pallet.setSequenceNo(sequence); pallet.setSource(source); pallet.setStatus("EXPECTED"); + pallet.setCreatedAt(now); pallet.setUpdatedAt(now); + return pallet; + } + private void validateQuery(UsTruckOrderRequests.Query q) { if (StringUtils.hasText(q.status()) && !STATUSES.contains(norm(q.status()))) throw badRequest("Unsupported truck order status"); if (StringUtils.hasText(q.transportType())) validateTransport(q.transportType()); if (q.requestedFrom() != null && q.requestedTo() != null && q.requestedFrom().isAfter(q.requestedTo())) throw badRequest("requestedFrom must not be after requestedTo"); } + private void validateTransport(String v) { if (!TRANSPORT_TYPES.contains(norm(v))) throw badRequest("Unsupported transport type"); } + private String hash(Object v) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(objectMapper.writeValueAsBytes(v))); } catch (JsonProcessingException | NoSuchAlgorithmException e) { throw new IllegalStateException("Cannot hash truck request", e); } } + private static void touch(UsTruckOrder o) { o.setVersion(value(o.getVersion()) + 1); o.setUpdatedAt(LocalDateTime.now()); } + private static void setText(String v, Consumer c) { if (StringUtils.hasText(v)) c.accept(v.trim()); } + private static void setPresent(String v, Consumer c) { if (v != null) c.accept(trim(v)); } + private static String trim(String v) { return v == null || v.isBlank() ? null : v.trim(); } + private static String norm(String v) { return v.trim().toUpperCase(); } + private static String defaultCurrency(String v) { return StringUtils.hasText(v) ? norm(v) : "USD"; } + private static int value(Integer v) { return v == null ? 0 : v; } private static long value(Long v) { return v == null ? 0 : v; } + private static ResponseStatusException badRequest(String m) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, m); } + private static ResponseStatusException conflict(String m) { return new ResponseStatusException(HttpStatus.CONFLICT, m); } + private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "US truck order not found"); } +} diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml new file mode 100644 index 000000000..250cdcb8d --- /dev/null +++ b/lastleg-service/src/main/resources/application.yml @@ -0,0 +1,44 @@ +server: + port: ${LASTLEG_PORT:9303} +spring: + sql: + init: + mode: always + encoding: UTF-8 + schema-locations: classpath:db/lastleg.sql + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6380} + application: + name: lastleg-service + config: + import: + - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true + - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + cloud: + nacos: + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} + username: ${NACOS_USERNAME:nacos} + password: ${NACOS_PASSWORD:nacos} + discovery: + enabled: ${NACOS_DISCOVERY_ENABLED:true} + register-enabled: ${NACOS_REGISTER_ENABLED:true} + group: ${NACOS_GROUP:BAIYUE} + namespace: ${NACOS_NAMESPACE:public} + config.namespace: ${NACOS_NAMESPACE:public} + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${LASTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${LASTLEG_DB_USERNAME:root} + password: ${LASTLEG_DB_PASSWORD:123456} +management.endpoints.web.exposure.include: health,info,prometheus + +baiyue: + security: + internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} + jwt-mode: jwks + session-mode: claims + issuer: ${JWT_ISSUER:auth-service} + audience: ${JWT_AUDIENCE:lastleg-api} + jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql new file mode 100644 index 000000000..6de76faf9 --- /dev/null +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -0,0 +1,1139 @@ +CREATE DATABASE IF NOT EXISTS baiyue_lastleg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_loading_event_inbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一标识', + customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件负载 SHA-256 摘要', + payload_json JSON NOT NULL COMMENT '事件冻结负载 JSON', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态:RECEIVED/PROCESSED', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + last_error VARCHAR(1000) NULL COMMENT '最近一次处理错误', + PRIMARY KEY(event_id), + UNIQUE KEY uk_ll_loading_inbox_tenant_key (customer_id,event_type,event_id) +) ENGINE=InnoDB COMMENT='头程装柜完成事件接收幂等箱'; + +CREATE TABLE IF NOT EXISTS ll_order ( + id BIGINT NOT NULL COMMENT '雪花 ID', + order_no VARCHAR(32) NOT NULL COMMENT '尾程单号', + customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型', + inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统', + source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键', + root_inbound_id VARCHAR(128) COMMENT '根入仓单 ID', + source_order_id VARCHAR(128) COMMENT '来源订单 ID', + source_order_no VARCHAR(128) COMMENT '来源订单号', + root_order_id VARCHAR(128) COMMENT '根订单 ID', + customer_name VARCHAR(255) COMMENT '货主名称', + overseas_warehouse_id VARCHAR(128) COMMENT '海外仓 ID', + overseas_warehouse_name VARCHAR(255) COMMENT '海外仓名称', + transfer_warehouse_id VARCHAR(128) COMMENT '转入目的仓 ID', + transfer_warehouse_code VARCHAR(128) COMMENT '转入目的仓编码', + transfer_warehouse_name VARCHAR(255) COMMENT '转入目的仓名称', + fba_warehouse_code VARCHAR(128) COMMENT 'FBA 仓库编码', + container_no VARCHAR(64) COMMENT '集装箱号', + container_status VARCHAR(32) DEFAULT '待转柜' COMMENT '货柜状态', + abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是', + detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + picked_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已拣箱数', + shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数', + volume_cbm DECIMAL(18,4) COMMENT '体积(立方米)', + product_quantity INT COMMENT '货物数量', + volume_weight_kg DECIMAL(18,4) COMMENT '体积重量(千克)', + estimated_arrival_at DATETIME(3) COMMENT '预计到港时间', + actual_arrival_at DATETIME(3) COMMENT '实际到港时间', + estimated_warehouse_at DATETIME(3) COMMENT '预计到仓时间', + inbound_order_no VARCHAR(128) COMMENT '入库单号', + outbound_order_no VARCHAR(128) COMMENT '出库单号', + delivery_order_no VARCHAR(128) COMMENT '派送单号', + relabel_order_no VARCHAR(128) COMMENT '换标单号', + relabel_status VARCHAR(32) COMMENT '换标状态', + pallet_merge_flag VARCHAR(32) COMMENT '合托标记', + current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态', + sub_order_status VARCHAR(255) NULL COMMENT '子单据状态汇总', + private_remark VARCHAR(1000) COMMENT '备注(仅自己)', + customer_remark VARCHAR(1000) COMMENT '备注(客户)', + warehouse_remark VARCHAR(1000) COMMENT '备注(仓库)', + source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', + source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID', + source_loading_plan_version BIGINT COMMENT '来源装柜计划版本', + source_handover_version BIGINT COMMENT '交接版本', + source_payload_hash VARCHAR(71) NOT 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_order_source (customer_id,source_region,source_inbound_id), + KEY idx_ll_order_group(customer_id,order_no), + KEY idx_ll_order_container(customer_id,container_no), + KEY idx_ll_order_list(customer_id,current_status,created_at) +) ENGINE=InnoDB COMMENT='尾程订单主表'; + +CREATE TABLE IF NOT EXISTS ll_order_detail ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键', + order_id BIGINT NOT NULL COMMENT '尾程订单 ID', + source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细 ID', + sku_code VARCHAR(128) COMMENT 'SKU 编码', + product_name_zh VARCHAR(255) COMMENT '中文品名', + product_name_en VARCHAR(255) COMMENT '英文品名', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + product_quantity INT COMMENT '数量', + pallet_count INT COMMENT '托数', + gross_weight_kg DECIMAL(18,4) COMMENT '毛重(千克)', + net_weight_kg DECIMAL(18,4) COMMENT '净重(千克)', + dimensional_weight_kg DECIMAL(18,4) COMMENT '体积重量(千克)', + volume_cbm DECIMAL(18,4) COMMENT '体积(立方米)', + destination_warehouse_id VARCHAR(128) COMMENT '目的仓 ID', + destination_warehouse_code VARCHAR(128) COMMENT '目的仓编码', + destination_warehouse_name VARCHAR(255) COMMENT '目的仓名称', + fba_tracking_no VARCHAR(255) COMMENT 'FBA跟踪号', + shipment_no VARCHAR(255) COMMENT '货件编号', + shipping_mark VARCHAR(255) COMMENT '唛头', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_detail_source(order_id,source_inbound_detail_id), + KEY idx_ll_detail_order(order_id) +) ENGINE=InnoDB COMMENT='尾程订单实装商品冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_loading_order_status_history ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键', + order_id BIGINT NOT NULL COMMENT '尾程订单 ID', + from_status VARCHAR(32) COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', + event_id VARCHAR(128) COMMENT '触发事件 ID', + remark VARCHAR(500) COMMENT '状态变更备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录时间', + PRIMARY KEY(id), + KEY idx_ll_loading_history_order(order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单状态变更历史'; + +CREATE TABLE IF NOT EXISTS ll_event_conflict ( + id BIGINT NOT NULL COMMENT '冲突记录主键', event_id VARCHAR(128) NOT NULL COMMENT '冲突事件 ID', + customer_id BIGINT NOT NULL COMMENT '租户 ID', conflict_type VARCHAR(64) NOT NULL COMMENT '冲突类型', + conflict_message VARCHAR(512) NOT NULL COMMENT '冲突说明', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY(id), KEY idx_ll_conflict_event(event_id,customer_id) +) ENGINE=InnoDB COMMENT='事件冲突记录'; + +CREATE TABLE IF NOT EXISTS ll_event_inbox ( + id BIGINT NOT NULL COMMENT '事件接收记录主键', event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一 ID', + customer_id BIGINT NOT NULL COMMENT '租户 ID', event_type VARCHAR(128) NOT NULL COMMENT '事件类型', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合 ID', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要', + status VARCHAR(32) NOT NULL COMMENT '消费状态', error_message VARCHAR(512) NULL COMMENT '错误信息', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_inbox_event(event_id), KEY idx_ll_inbox_source(customer_id,aggregate_id) +) ENGINE=InnoDB COMMENT='通用事件接收幂等箱'; + +CREATE TABLE IF NOT EXISTS ll_inbound_order ( + id BIGINT NOT NULL COMMENT '入仓单主键', customer_id BIGINT NOT NULL COMMENT '租户 ID', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + source_inbound_id VARCHAR(128) NOT NULL COMMENT '来源入仓分片 ID', root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID', + source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', source_order_no VARCHAR(128) NULL COMMENT '来源订单号', root_order_id VARCHAR(128) NULL COMMENT '根订单 ID', + inbound_code VARCHAR(128) NULL COMMENT '入仓码', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', product_quantity INT NULL COMMENT '数量', pallet_count INT NULL COMMENT '托数', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', + source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '装柜计划 ID', + source_loading_plan_version BIGINT NOT NULL COMMENT '装柜计划版本', handover_version BIGINT NOT NULL COMMENT '交接版本', payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要', + status VARCHAR(32) NOT NULL COMMENT '入仓单状态', exception_flag VARCHAR(32) NOT NULL DEFAULT 'NONE' COMMENT '异常标记', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_order_source(customer_id,source_region,source_inbound_id), UNIQUE KEY uk_ll_order_event(source_event_id,source_inbound_id) +) ENGINE=InnoDB COMMENT='尾程入仓单快照'; + +CREATE TABLE IF NOT EXISTS ll_inbound_order_item ( + id BIGINT NOT NULL COMMENT '入仓明细主键', customer_id BIGINT NOT NULL COMMENT '租户 ID', order_id BIGINT NOT NULL COMMENT '入仓单 ID', + source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细 ID', package_count INT NOT NULL COMMENT '箱数', product_quantity INT NULL COMMENT '数量', pallet_count INT NULL COMMENT '托数', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', dimensional_weight_kg DECIMAL(18,3) NULL COMMENT '体积重量(千克)', volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', + fba_tracking_no VARCHAR(128) NULL COMMENT 'FBA跟踪号', shipment_no VARCHAR(128) NULL COMMENT '货件编号', shipping_mark VARCHAR(255) NULL COMMENT '唛头', + sku_code VARCHAR(128) NULL COMMENT 'SKU 编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', product_name_en VARCHAR(255) NULL COMMENT '英文品名', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_item_order(customer_id,order_id) +) ENGINE=InnoDB COMMENT='尾程入仓商品明细快照'; + +CREATE TABLE IF NOT EXISTS ll_order_status_history ( + id BIGINT NOT NULL COMMENT '订单状态历史主键', customer_id BIGINT NOT NULL COMMENT '租户 ID', order_id BIGINT NOT NULL COMMENT '订单 ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', reason VARCHAR(255) NULL COMMENT '变更原因', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间', PRIMARY KEY(id) +) ENGINE=InnoDB COMMENT='尾程订单状态历史'; + +-- 幂等注释迁移:CREATE TABLE IF NOT EXISTS 对已有表不会更新表/字段注释。 +ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等箱'; +ALTER TABLE ll_order COMMENT='尾程订单主表'; +ALTER TABLE ll_order_detail COMMENT='尾程订单实装商品冻结明细'; +ALTER TABLE ll_loading_order_status_history COMMENT='尾程订单状态变更历史'; +ALTER TABLE ll_event_conflict COMMENT='事件冲突记录'; +ALTER TABLE ll_event_inbox COMMENT='通用事件接收幂等箱'; +ALTER TABLE ll_inbound_order COMMENT='尾程入仓单快照'; +ALTER TABLE ll_inbound_order_item COMMENT='尾程入仓商品明细快照'; +ALTER TABLE ll_order_status_history COMMENT='尾程订单状态历史'; + +ALTER TABLE ll_event_conflict + MODIFY COLUMN id BIGINT NOT NULL COMMENT '冲突记录主键', MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT '冲突事件 ID', + MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT '租户 ID', MODIFY COLUMN conflict_type VARCHAR(64) NOT NULL COMMENT '冲突类型', + MODIFY COLUMN conflict_message VARCHAR(512) NOT NULL COMMENT '冲突说明', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'; +ALTER TABLE ll_event_inbox + MODIFY COLUMN id BIGINT NOT NULL COMMENT '事件接收记录主键', MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一 ID', MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT '租户 ID', + MODIFY COLUMN event_type VARCHAR(128) NOT NULL COMMENT '事件类型', MODIFY COLUMN aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', MODIFY COLUMN aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合 ID', MODIFY COLUMN aggregate_version BIGINT NOT NULL COMMENT '聚合版本', MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要', MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '消费状态', MODIFY COLUMN error_message VARCHAR(512) NULL COMMENT '错误信息', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', MODIFY COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; +ALTER TABLE ll_inbound_order + MODIFY COLUMN id BIGINT NOT NULL COMMENT '入仓单主键', MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT '租户 ID', MODIFY COLUMN source_region VARCHAR(32) NOT NULL COMMENT '来源区域', MODIFY COLUMN source_inbound_id VARCHAR(128) NOT NULL COMMENT '来源入仓分片 ID', MODIFY COLUMN root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID', MODIFY COLUMN source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', MODIFY COLUMN source_order_no VARCHAR(128) NULL COMMENT '来源订单号', MODIFY COLUMN root_order_id VARCHAR(128) NULL COMMENT '根订单 ID', MODIFY COLUMN inbound_code VARCHAR(128) NULL COMMENT '入仓码', MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', MODIFY COLUMN product_quantity INT NULL COMMENT '数量', MODIFY COLUMN pallet_count INT NULL COMMENT '托数', MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', MODIFY COLUMN net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', MODIFY COLUMN volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '装柜计划 ID', MODIFY COLUMN source_loading_plan_version BIGINT NOT NULL COMMENT '装柜计划版本', MODIFY COLUMN handover_version BIGINT NOT NULL COMMENT '交接版本', MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要', MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '入仓单状态', MODIFY COLUMN exception_flag VARCHAR(32) NOT NULL DEFAULT 'NONE' COMMENT '异常标记', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', MODIFY COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; +ALTER TABLE ll_inbound_order_item + MODIFY COLUMN id BIGINT NOT NULL COMMENT '入仓明细主键', MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT '租户ID', MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '入仓单ID', MODIFY COLUMN source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细ID', MODIFY COLUMN package_count INT NOT NULL COMMENT '箱数', MODIFY COLUMN product_quantity INT NULL COMMENT '数量', MODIFY COLUMN pallet_count INT NULL COMMENT '托数', MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', MODIFY COLUMN net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', MODIFY COLUMN dimensional_weight_kg DECIMAL(18,3) NULL COMMENT '体积重量(千克)', MODIFY COLUMN volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', MODIFY COLUMN fba_tracking_no VARCHAR(128) NULL COMMENT 'FBA跟踪号', MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT '货件编号', MODIFY COLUMN shipping_mark VARCHAR(255) NULL COMMENT '唛头', MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT 'SKU编码', MODIFY COLUMN product_name_zh VARCHAR(255) NULL COMMENT '中文品名', MODIFY COLUMN product_name_en VARCHAR(255) NULL COMMENT '英文品名', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'; +ALTER TABLE ll_order_status_history + MODIFY COLUMN id BIGINT NOT NULL COMMENT '订单状态历史主键', MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT '租户 ID', MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '订单 ID', MODIFY COLUMN from_status VARCHAR(32) NULL COMMENT '变更前状态', MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', MODIFY COLUMN reason VARCHAR(255) NULL COMMENT '变更原因', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间'; + +ALTER TABLE ll_loading_event_inbox + MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一标识', + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + MODIFY COLUMN event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + MODIFY COLUMN event_version INT NOT NULL COMMENT '事件契约版本', + MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件负载 SHA-256 摘要', + MODIFY COLUMN payload_json JSON NOT NULL COMMENT '事件冻结负载 JSON', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态:RECEIVED/PROCESSED', + MODIFY COLUMN received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + MODIFY COLUMN processed_at DATETIME(3) NULL COMMENT '处理完成时间', + MODIFY COLUMN last_error VARCHAR(1000) NULL COMMENT '最近一次处理错误'; + +ALTER TABLE ll_order + MODIFY COLUMN id BIGINT NOT NULL COMMENT '雪花 ID', + MODIFY COLUMN order_no VARCHAR(32) NOT NULL COMMENT '尾程单号', + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + MODIFY COLUMN order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型', + MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码', + MODIFY COLUMN source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统', + MODIFY COLUMN source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键', + MODIFY COLUMN root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID', + MODIFY COLUMN source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', + MODIFY COLUMN source_order_no VARCHAR(128) NULL COMMENT '来源订单号', + MODIFY COLUMN root_order_id VARCHAR(128) NULL COMMENT '根订单 ID', + MODIFY COLUMN customer_name VARCHAR(255) NULL COMMENT '货主名称', + MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓 ID', + MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称', + MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA 仓库编码', + MODIFY COLUMN container_no VARCHAR(64) NULL COMMENT '集装箱号', + MODIFY COLUMN container_status VARCHAR(32) NULL DEFAULT '待转柜' COMMENT '货柜状态', + MODIFY COLUMN abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是', + MODIFY COLUMN detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是', + MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数', + MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米)', + MODIFY COLUMN product_quantity INT NULL COMMENT '货物数量', + MODIFY COLUMN volume_weight_kg DECIMAL(18,4) NULL COMMENT '体积重量(千克)', + MODIFY COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT '预计到港时间', + MODIFY COLUMN actual_arrival_at DATETIME(3) NULL COMMENT '实际到港时间', + MODIFY COLUMN estimated_warehouse_at DATETIME(3) NULL COMMENT '预计到仓时间', + MODIFY COLUMN inbound_order_no VARCHAR(128) NULL COMMENT '入库单号', + MODIFY COLUMN outbound_order_no VARCHAR(128) NULL COMMENT '出库单号', + MODIFY COLUMN delivery_order_no VARCHAR(128) NULL COMMENT '派送单号', + MODIFY COLUMN relabel_order_no VARCHAR(128) NULL COMMENT '换标单号', + MODIFY COLUMN relabel_status VARCHAR(32) NULL COMMENT '换标状态', + MODIFY COLUMN pallet_merge_flag VARCHAR(32) NULL COMMENT '合托标记', + MODIFY COLUMN current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态', + MODIFY COLUMN private_remark VARCHAR(1000) NULL COMMENT '备注(仅自己)', + MODIFY COLUMN customer_remark VARCHAR(1000) NULL COMMENT '备注(客户)', + MODIFY COLUMN warehouse_remark VARCHAR(1000) NULL COMMENT '备注(仓库)', + MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', + MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID', + MODIFY COLUMN source_loading_plan_version BIGINT NULL COMMENT '来源装柜计划版本', + MODIFY COLUMN source_handover_version BIGINT NULL COMMENT '交接版本', + MODIFY COLUMN source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源事件负载摘要', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间'; + +-- Existing installations: convert the row identity to an assigned Snowflake ID and +-- make order_no a container/loading-plan grouping number rather than a row-unique key. +SET @ll_add_transfer_warehouse_id = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_id VARCHAR(128) NULL COMMENT ''转入目的仓ID'' AFTER overseas_warehouse_name', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND column_name = 'transfer_warehouse_id' +); +PREPARE ll_add_transfer_warehouse_id_stmt FROM @ll_add_transfer_warehouse_id; +EXECUTE ll_add_transfer_warehouse_id_stmt; +DEALLOCATE PREPARE ll_add_transfer_warehouse_id_stmt; + +SET @ll_add_transfer_warehouse_code = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_code VARCHAR(128) NULL COMMENT ''转入目的仓编码'' AFTER transfer_warehouse_id', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND column_name = 'transfer_warehouse_code' +); +PREPARE ll_add_transfer_warehouse_code_stmt FROM @ll_add_transfer_warehouse_code; +EXECUTE ll_add_transfer_warehouse_code_stmt; +DEALLOCATE PREPARE ll_add_transfer_warehouse_code_stmt; + +SET @ll_add_transfer_warehouse_name = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_name VARCHAR(255) NULL COMMENT ''转入目的仓名称'' AFTER transfer_warehouse_code', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND column_name = 'transfer_warehouse_name' +); +PREPARE ll_add_transfer_warehouse_name_stmt FROM @ll_add_transfer_warehouse_name; +EXECUTE ll_add_transfer_warehouse_name_stmt; +DEALLOCATE PREPARE ll_add_transfer_warehouse_name_stmt; + +ALTER TABLE ll_order + MODIFY COLUMN transfer_warehouse_id VARCHAR(128) NULL COMMENT '转入目的仓ID', + MODIFY COLUMN transfer_warehouse_code VARCHAR(128) NULL COMMENT '转入目的仓编码', + MODIFY COLUMN transfer_warehouse_name VARCHAR(255) NULL COMMENT '转入目的仓名称'; + +SET @ll_drop_order_no_unique = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE ll_order DROP INDEX uk_ll_order_no', + 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND index_name = 'uk_ll_order_no' AND non_unique = 0 +); +PREPARE ll_drop_order_no_unique_stmt FROM @ll_drop_order_no_unique; +EXECUTE ll_drop_order_no_unique_stmt; +DEALLOCATE PREPARE ll_drop_order_no_unique_stmt; + +SET @ll_add_order_group_index = ( + SELECT IF(COUNT(*) = 0, + 'CREATE INDEX idx_ll_order_group ON ll_order(customer_id,order_no)', + 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND index_name = 'idx_ll_order_group' +); +PREPARE ll_add_order_group_index_stmt FROM @ll_add_order_group_index; +EXECUTE ll_add_order_group_index_stmt; +DEALLOCATE PREPARE ll_add_order_group_index_stmt; + +ALTER TABLE ll_order_detail + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键', + MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单 ID', + MODIFY COLUMN source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细 ID', + MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT 'SKU 编码', + MODIFY COLUMN product_name_zh VARCHAR(255) NULL COMMENT '中文品名', + MODIFY COLUMN product_name_en VARCHAR(255) NULL COMMENT '英文品名', + MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + MODIFY COLUMN product_quantity INT NULL COMMENT '数量', + MODIFY COLUMN pallet_count INT NULL COMMENT '托数', + MODIFY COLUMN gross_weight_kg DECIMAL(18,4) NULL COMMENT '毛重(千克)', + MODIFY COLUMN net_weight_kg DECIMAL(18,4) NULL COMMENT '净重(千克)', + MODIFY COLUMN dimensional_weight_kg DECIMAL(18,4) NULL COMMENT '体积重量(千克)', + MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米)', + MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', + MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', + MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', + MODIFY COLUMN fba_tracking_no VARCHAR(255) NULL COMMENT 'FBA跟踪号', + MODIFY COLUMN shipment_no VARCHAR(255) NULL COMMENT '货件编号', + MODIFY COLUMN shipping_mark VARCHAR(255) NULL COMMENT '唛头'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan ( + id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)', + plan_no VARCHAR(32) NOT NULL COMMENT '入库单号', + customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', + status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态', + line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量', + total_volume_cbm DECIMAL(18,4) 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_inbound_plan_no (plan_no), + UNIQUE KEY uk_ll_inbound_plan_container (customer_id, container_no) +) ENGINE=InnoDB COMMENT='尾程入库计划表:每个租户的每个集装箱生成一张入库单'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( + id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', + inbound_code 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 '目的仓名称快照', + volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '入仓码体积(立方米)', + planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_line_order (plan_id, order_id), + KEY idx_ll_inbound_line_plan (plan_id) +) ENGINE=InnoDB COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet ( + id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + pallet_no VARCHAR(64) 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 '目的仓名称快照', + capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量,固定为2立方米', + allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)', + sequence_no INT NOT NULL COMMENT '计划内托盘序号', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_pallet_no (pallet_no), + UNIQUE KEY uk_ll_inbound_pallet_sequence (plan_id, sequence_no), + KEY idx_ll_inbound_pallet_plan (plan_id) +) ENGINE=InnoDB COMMENT='尾程入库计划托盘表:按目的仓隔离合托'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation ( + id BIGINT NOT NULL COMMENT '托盘分配记录ID(雪花ID)', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + line_id BIGINT NOT NULL COMMENT '入库计划明细ID', + pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '该明细分配到托盘的体积(立方米)', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_allocation (line_id, pallet_id), + KEY idx_ll_inbound_allocation_plan (plan_id), + KEY idx_ll_inbound_allocation_pallet (pallet_id) +) ENGINE=InnoDB COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'; + +-- 修复既有入库计划表中的英文注释。 +ALTER TABLE ll_inbound_plan COMMENT='尾程入库计划表:每个租户的每个集装箱生成一张入库单'; +ALTER TABLE ll_inbound_plan + MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)', + MODIFY COLUMN plan_no VARCHAR(32) NOT NULL COMMENT '入库单号', + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + MODIFY COLUMN container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态', + MODIFY COLUMN line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', + MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量', + MODIFY COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '集装箱总体积(立方米)', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间'; + +ALTER TABLE ll_inbound_plan_line COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; +ALTER TABLE ll_inbound_plan_line + MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', + MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID', + MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单ID', + MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT '头程入仓码快照', + MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + MODIFY COLUMN volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '入仓码体积(立方米)', + MODIFY COLUMN planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量'; + +ALTER TABLE ll_inbound_plan_pallet COMMENT='尾程入库计划托盘表:按目的仓隔离合托'; +ALTER TABLE ll_inbound_plan_pallet + MODIFY COLUMN id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)', + MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID', + MODIFY COLUMN pallet_no VARCHAR(64) NOT NULL COMMENT '计划托盘编号', + MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量,固定为2立方米', + MODIFY COLUMN allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)', + MODIFY COLUMN sequence_no INT NOT NULL COMMENT '计划内托盘序号'; + +ALTER TABLE ll_inbound_plan_pallet_allocation COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'; +ALTER TABLE ll_inbound_plan_pallet_allocation + MODIFY COLUMN id BIGINT NOT NULL COMMENT '托盘分配记录ID(雪花ID)', + MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID', + MODIFY COLUMN line_id BIGINT NOT NULL COMMENT '入库计划明细ID', + MODIFY COLUMN pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + MODIFY COLUMN allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '该明细分配到托盘的体积(立方米)'; + +-- Additive inbound picking schema. Guarded statements preserve existing rows on restart. +ALTER TABLE ll_inbound_plan MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='required_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN required_package_count INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_pallet_id BIGINT NULL')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_location_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_location_id BIGINT NULL')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_resource_lock_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_resource_lock_id BIGINT NULL')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='assignment_status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN assignment_status VARCHAR(32) NOT NULL DEFAULT ''UNASSIGNED''')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN locked_at DATETIME(3) NULL')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_package_count INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='planned_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN planned_package_count INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='counted_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN counted_package_count INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_COUNT''')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN version INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; + +CREATE TABLE IF NOT EXISTS ll_inbound_pda_task ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID', + task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '业务状态', + operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(255) NULL COMMENT '操作人姓名', + pushed_at DATETIME(3) NULL COMMENT '任务下发时间', started_at DATETIME(3) NULL COMMENT '开始时间', completed_at DATETIME(3) NULL COMMENT '完成时间', + version INT 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_inbound_pda_task_plan (customer_id, plan_id), + UNIQUE KEY uk_ll_inbound_pda_task_no (task_no) +) ENGINE=InnoDB COMMENT='入仓PDA作业任务'; + +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 '业务幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容JSON', result_json LONGTEXT NOT NULL COMMENT '处理结果JSON', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pda_operation_key (customer_id, idempotency_key), + KEY idx_ll_inbound_pda_operation_task (task_id), KEY idx_ll_inbound_pda_operation_allocation (allocation_id) +) ENGINE=InnoDB COMMENT='入仓PDA操作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_inbound_pallet_merge ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', + merge_group_no VARCHAR(64) NOT NULL COMMENT '合托分组号', source_pallet_id BIGINT NOT NULL COMMENT '来源托盘ID', target_pallet_id BIGINT NOT NULL COMMENT '目标托盘ID', + source_allocation_id BIGINT NOT NULL COMMENT '来源分配记录ID', target_allocation_id BIGINT NOT NULL COMMENT '目标分配记录ID', + source_line_id BIGINT NOT NULL COMMENT '来源计划明细ID', target_line_id BIGINT NOT NULL COMMENT '目标计划明细ID', + source_order_id BIGINT NOT NULL COMMENT '来源订单ID', target_order_id BIGINT NOT NULL COMMENT '目标订单ID', + moved_package_count INT NOT NULL COMMENT '移动箱数', moved_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '移动体积(立方米)', + source_pallet_scrapped TINYINT NOT NULL DEFAULT 0 COMMENT '来源托盘是否已报废', status VARCHAR(32) NOT NULL COMMENT '业务状态', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + KEY idx_ll_inbound_merge_task (customer_id, task_id), KEY idx_ll_inbound_merge_group (merge_group_no) +) ENGINE=InnoDB COMMENT='入仓托盘合托记录'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL AFTER inbound_code')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id +SET l.fba_warehouse_code=o.fba_warehouse_code +WHERE l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL; + +CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + appointment_no VARCHAR(64) NOT NULL COMMENT '预约单号', + appointment_at DATETIME(3) NOT NULL COMMENT '预约时间', + appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '预约状态', + appointment_remark VARCHAR(1000) NULL COMMENT '预约备注', + fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + selected_count INT NOT NULL DEFAULT 0 COMMENT '已选明细数量', + created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_fba_appointment_no (customer_id, appointment_no), + KEY idx_ll_fba_appointment_list (customer_id, appointment_status, fba_warehouse_code, created_at) +) ENGINE=InnoDB COMMENT='FBA入仓预约单'; + +CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item ( + id BIGINT NOT NULL COMMENT '主键ID', + appointment_id BIGINT NOT NULL COMMENT '预约单ID', + plan_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 '入仓单号快照', + fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_fba_appointment_item_line (appointment_id, line_id), + KEY idx_ll_fba_appointment_item_plan (plan_id), + KEY idx_ll_fba_appointment_item_order (order_id) +) ENGINE=InnoDB COMMENT='FBA入仓预约明细'; + +ALTER TABLE ll_loading_order_status_history + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键', + MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单 ID', + MODIFY COLUMN from_status VARCHAR(32) NULL COMMENT '变更前状态', + MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', + MODIFY COLUMN event_id VARCHAR(128) NULL COMMENT '触发事件 ID', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '状态变更备注', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录时间'; + +CREATE TABLE IF NOT EXISTS ll_inventory_balance ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + overseas_warehouse_code VARCHAR(128) NULL COMMENT '海外仓编码快照', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', + destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', + inbound_order_no VARCHAR(128) NULL COMMENT '入仓单号快照', + inbound_code VARCHAR(128) NOT 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 '收货处理中箱数', + procurement_packages BIGINT NOT NULL DEFAULT 0 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_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_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='入仓单维度实时库存余额'; + +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', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目的仓编码快照', + pallet_id BIGINT NOT NULL COMMENT '托盘ID', pallet_no VARCHAR(64) NOT NULL COMMENT '托盘号', + batch_no VARCHAR(128) NOT NULL DEFAULT '' COMMENT '库存批次号', warehouse_location_id BIGINT NULL COMMENT '仓库库位ID', + warehouse_location_code VARCHAR(128) NOT 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 '已拣箱数', + receiving_packages BIGINT NOT NULL DEFAULT 0 COMMENT '收货处理中箱数', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' 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_inventory_position (balance_id,pallet_id,batch_no), + KEY idx_ll_inventory_position_pallet (customer_id,pallet_id), + KEY idx_ll_inventory_position_location (customer_id,overseas_warehouse_id,warehouse_location_code) +) ENGINE=InnoDB COMMENT='托盘库位批次库存明细'; + +CREATE TABLE IF NOT EXISTS ll_inventory_ledger ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', position_id BIGINT NULL COMMENT '库存仓位记录ID', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目的仓编码快照', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', + pallet_id BIGINT NULL COMMENT '托盘ID', pallet_no VARCHAR(64) NULL COMMENT '托盘号', batch_no VARCHAR(128) NULL COMMENT '库存批次号', + warehouse_location_code VARCHAR(128) NULL COMMENT '仓库库位编码', sku_code VARCHAR(128) NULL COMMENT 'SKU编码', carton_mark VARCHAR(128) NULL COMMENT '箱唛', carton_packages BIGINT NULL COMMENT '箱内箱数', + business_type VARCHAR(32) NOT NULL COMMENT '业务类型', + business_order_id BIGINT NULL COMMENT '业务单据ID', business_order_no VARCHAR(128) NULL COMMENT '业务单号', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + inbound_order_no VARCHAR(128) NULL COMMENT '入仓单号快照', outbound_order_no VARCHAR(128) NULL COMMENT '出库单号', + source_event_id VARCHAR(128) NULL COMMENT '来源事件ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + status_from VARCHAR(32) NULL COMMENT '操作前状态', status_to VARCHAR(32) NULL COMMENT '操作后状态', + before_packages BIGINT NOT NULL COMMENT '变动前箱数', delta_packages BIGINT NOT NULL COMMENT '箱数变动量', after_packages BIGINT NOT NULL COMMENT '变动后箱数', + operator_id VARCHAR(128) NULL COMMENT '操作人ID', remark VARCHAR(1000) NULL COMMENT '备注', + occurred_at DATETIME(3) NOT NULL COMMENT '业务发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_ledger_idempotency (customer_id,idempotency_key), + KEY idx_ll_inventory_ledger_query (customer_id,overseas_warehouse_id,occurred_at), + KEY idx_ll_inventory_ledger_inbound (customer_id,inbound_code,occurred_at), + KEY idx_ll_inventory_ledger_business (customer_id,business_type,business_order_no) +) ENGINE=InnoDB COMMENT='不可变库存变动流水'; + +CREATE TABLE IF NOT EXISTS ll_inventory_operation ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型', + request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', status VARCHAR(32) NOT NULL DEFAULT 'PROCESSING' COMMENT '库存命令处理状态', + result_json LONGTEXT NULL COMMENT '处理结果JSON', last_error VARCHAR(2000) 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_inventory_operation_key (customer_id,idempotency_key), + KEY idx_ll_inventory_operation_status (customer_id,status,updated_at) +) ENGINE=InnoDB COMMENT='库存命令幂等记录'; + +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 '入仓单号快照', + 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), + 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库存实收快照'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_order ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + vehicle_plate VARCHAR(64) NULL COMMENT '车牌号', + driver_name VARCHAR(128) NULL COMMENT '司机姓名', + driver_phone VARCHAR(64) NULL COMMENT '司机电话', + carrier_name VARCHAR(255) NULL COMMENT '承运商名称', + route VARCHAR(255) NULL COMMENT '运输路线', + depart_at DATETIME(3) NULL COMMENT '发车时间', + fba_appointment_no VARCHAR(64) NULL COMMENT 'FBA预约号', + fba_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'FBA预约状态', + vehicle_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '车辆预约状态', + estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_DEPARTURE' COMMENT '业务状态', + item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + picked_package_count INT NOT NULL DEFAULT 0 COMMENT 'PDA累计已拣箱数', + shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数', + partial_completion_reason VARCHAR(1000) NULL COMMENT '部分完成原因', + created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_dispatch_order_no (customer_id,dispatch_no), + KEY idx_ll_dispatch_order_list (customer_id,status,depart_at), + KEY idx_ll_dispatch_order_vehicle (customer_id,vehicle_plate) +) ENGINE=InnoDB COMMENT='尾程派车单'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_item ( + id BIGINT NOT NULL COMMENT '主键ID', + dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + plan_id BIGINT NULL COMMENT '入仓计划ID', + plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', + order_id BIGINT NULL COMMENT '订单ID', + order_no VARCHAR(64) NULL COMMENT '订单号', + order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', + inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA仓库编码', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', + source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id), + KEY idx_ll_dispatch_item_order (customer_id,order_id), + KEY idx_ll_dispatch_item_inbound (customer_id,inbound_code), + KEY idx_ll_dispatch_item_pallet (customer_id,plan_pallet_id,warehouse_pallet_id) +) ENGINE=InnoDB COMMENT='派车货物冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + source_pallet_key VARCHAR(160) NOT NULL COMMENT '来源托盘业务键', + active_key VARCHAR(160) NULL COMMENT '生效中的资源排他键', + plan_id BIGINT NULL COMMENT '入仓计划ID', + plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', + dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + released_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_dispatch_active_pallet (customer_id,active_key), + KEY idx_ll_dispatch_pallet_lock_order (customer_id,dispatch_order_id,status), + KEY idx_ll_dispatch_pallet_lock_source (customer_id,source_pallet_key,status) +) ENGINE=InnoDB COMMENT='派车托盘排他锁'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_refs'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN order_refs VARCHAR(2000) NULL AFTER order_no')); +PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', + task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL COMMENT '业务状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + warehouse_id VARCHAR(128) NULL COMMENT '仓库ID', + operator_name VARCHAR(128) NULL COMMENT '操作人姓名', pushed_at DATETIME(3) NOT NULL COMMENT '任务下发时间', completed_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_dispatch_pda_task_dispatch (customer_id,dispatch_order_id), + UNIQUE KEY uk_ll_dispatch_pda_task_no (customer_id,task_no) +) ENGINE=InnoDB COMMENT='派车PDA拣货任务'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', + dispatch_item_id BIGINT NOT NULL COMMENT '派车明细ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位记录ID', + packages BIGINT NOT NULL COMMENT '本次操作箱数', product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + picked_at DATETIME(3) NOT NULL COMMENT '拣货时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_pick_key (customer_id,idempotency_key), + KEY idx_ll_dispatch_pda_pick_item (customer_id,task_id,dispatch_item_id) +) ENGINE=InnoDB COMMENT='派车PDA拣货记录'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NULL COMMENT 'PDA任务ID', + operation_type VARCHAR(32) NOT NULL COMMENT 'PDA操作类型', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + result_json LONGTEXT NULL COMMENT '处理结果JSON', status VARCHAR(32) NOT NULL COMMENT 'PDA操作处理状态', + 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_dispatch_pda_operation_key (customer_id,idempotency_key) +) ENGINE=InnoDB COMMENT='派车PDA操作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', pick_id BIGINT NOT NULL COMMENT 'PDA拣货记录ID', + receipt_id BIGINT NOT NULL COMMENT '入仓实收记录ID', order_id BIGINT NOT NULL COMMENT '订单ID', packages BIGINT NOT NULL COMMENT '本次操作箱数', + product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), KEY idx_ll_dispatch_pick_order_pick (customer_id,pick_id), + KEY idx_ll_dispatch_pick_order_receipt (customer_id,receipt_id) +) ENGINE=InnoDB COMMENT='PDA拣货订单归属记录'; + +-- Transfer a dispatch to delivery: one outbound header, many pallet rows, one delivery header. +CREATE TABLE IF NOT EXISTS ll_outbound_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + outbound_no VARCHAR(64) NOT NULL COMMENT '出库单号', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_OUTBOUND' COMMENT '业务状态', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_outbound_no (customer_id,outbound_no), + UNIQUE KEY uk_ll_outbound_idempotency (customer_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch (customer_id,dispatch_order_id), + KEY idx_ll_outbound_list (customer_id,status,created_at) +) ENGINE=InnoDB COMMENT='尾程出库单'; + +CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id), + KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code) +) ENGINE=InnoDB COMMENT='出库托盘明细'; + +CREATE TABLE IF NOT EXISTS ll_delivery_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + delivery_no VARCHAR(64) NOT NULL COMMENT '派送单号', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', outbound_no VARCHAR(64) NOT NULL COMMENT '出库单号', + dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', + status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '业务状态', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_delivery_no (customer_id,delivery_no), + UNIQUE KEY uk_ll_delivery_idempotency (customer_id,idempotency_key), UNIQUE KEY uk_ll_delivery_outbound (customer_id,outbound_order_id), + KEY idx_ll_delivery_list (customer_id,status,created_at) +) ENGINE=InnoDB COMMENT='尾程派送单'; + +-- Customer warehouse transfer: generated tail order plus selected historical order/inbound bindings. +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + transfer_no VARCHAR(64) NOT NULL COMMENT '转仓单号', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NOT NULL COMMENT '尾程订单号', + operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '作业仓库ID', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + delivery_address VARCHAR(1000) NOT NULL COMMENT '派送地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '派送方式', + estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计到达时间', fba_shipment_no VARCHAR(128) NOT NULL COMMENT 'FBA货件编号', + tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注', + need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + allocation_idempotency_key VARCHAR(255) NULL COMMENT '库存分配幂等键', + allocation_request_hash CHAR(64) NULL COMMENT '库存分配请求哈希', + outbound_idempotency_key VARCHAR(255) NULL COMMENT '出库幂等键', + outbound_request_hash CHAR(64) NULL COMMENT '出库请求哈希', + outbound_at DATETIME(3) NULL COMMENT '出库时间', + request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_wh_transfer_no (customer_id,transfer_no), + UNIQUE KEY uk_ll_wh_transfer_key (customer_id,idempotency_key), + UNIQUE KEY uk_ll_wh_transfer_tail_order (lastleg_order_id), + KEY idx_ll_wh_transfer_list (customer_id,status,created_at), KEY idx_ll_wh_transfer_inbound (customer_id,inbound_code) +) ENGINE=InnoDB COMMENT='客户转仓单'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_source_order ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + source_lastleg_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID', source_lastleg_order_no VARCHAR(32) NOT NULL COMMENT '来源尾程订单号', + inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + UNIQUE KEY uk_ll_wh_transfer_source (transfer_order_id,source_lastleg_order_id), + KEY idx_ll_wh_transfer_source_list (customer_id,transfer_order_id) +) ENGINE=InnoDB COMMENT='转仓来源历史订单绑定'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_item ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NOT NULL COMMENT '产品中文名称', + package_count INT NOT NULL COMMENT '箱数', shipping_mark VARCHAR(255) NULL COMMENT '运输唛头', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + KEY idx_ll_wh_transfer_item_list (customer_id,transfer_order_id), KEY idx_ll_wh_transfer_item_sku (customer_id,sku_code) +) ENGINE=InnoDB COMMENT='转仓货物冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_inventory_allocation ( + id BIGINT NOT NULL COMMENT '雪花主键', + transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + inventory_position_id BIGINT NOT NULL COMMENT '来源库存仓位记录ID', + inventory_balance_id BIGINT NOT NULL COMMENT '来源库存余额记录ID', + inbound_order_id BIGINT NOT NULL COMMENT '来源入仓单ID', + inbound_code VARCHAR(128) NOT NULL COMMENT '来源入仓单号快照', + pallet_id BIGINT NOT NULL COMMENT '来源托盘ID', + pallet_no VARCHAR(128) NOT NULL COMMENT '来源托盘号快照', + package_count INT NOT NULL COMMENT '分配箱数', + status VARCHAR(32) NOT NULL DEFAULT 'RESERVED' COMMENT '分配状态', + active_position_key VARCHAR(255) NULL COMMENT '生效中的仓位排他键', + allocated_at DATETIME(3) NOT NULL COMMENT '分配时间', + shipped_at DATETIME(3) 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_wh_transfer_allocation_position(transfer_order_id,inventory_position_id), + UNIQUE KEY uk_ll_wh_transfer_active_position(customer_id,active_position_key), + KEY idx_ll_wh_transfer_allocation_list(customer_id,transfer_order_id,status) +) ENGINE=InnoDB COMMENT='转仓单来源库存分配记录'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_receipt ( + id BIGINT NOT NULL COMMENT '雪花主键', + transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', + allocation_id BIGINT NOT NULL COMMENT '库存分配记录ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + source_inventory_position_id BIGINT NOT NULL COMMENT '来源库存仓位记录ID', + destination_inventory_balance_id BIGINT NOT NULL COMMENT '目标库存余额记录ID', + destination_inventory_position_id BIGINT NOT NULL COMMENT '目标库存仓位记录ID', + package_count INT NOT NULL COMMENT '收货箱数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '收货幂等键', + request_hash CHAR(64) NOT NULL COMMENT '收货请求哈希', + inventory_operation_key VARCHAR(255) NOT NULL COMMENT '库存操作幂等键', + created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_wh_transfer_receipt_source(transfer_order_id,source_inventory_position_id), + UNIQUE KEY uk_ll_wh_transfer_receipt_key(customer_id,idempotency_key), + UNIQUE KEY uk_ll_wh_transfer_receipt_operation(customer_id,inventory_operation_key), + KEY idx_ll_wh_transfer_receipt_list(customer_id,transfer_order_id,received_at) +) ENGINE=InnoDB COMMENT='转仓来源仓位的目标库存收货记录'; + +-- Customer relabel application: header, item snapshots, and immutable process history. +CREATE TABLE IF NOT EXISTS ll_relabel_application ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + relabel_no VARCHAR(64) NOT NULL COMMENT '换标申请单号', + lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', + customer_order_no VARCHAR(128) NOT NULL COMMENT '客户订单号', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + label_deadline DATETIME(3) NOT NULL COMMENT '换标截止时间', + contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', + contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', + remark VARCHAR(1000) NULL COMMENT '备注', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_PROCESSING' COMMENT '业务状态', + item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量', + total_package_count INT NOT NULL DEFAULT 0 COMMENT '总箱数', + notified_package_count INT NOT NULL DEFAULT 0 COMMENT '已通知箱数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_relabel_application_no (customer_id,relabel_no), + UNIQUE KEY uk_ll_relabel_application_idempotency (customer_id,idempotency_key), + UNIQUE KEY uk_ll_relabel_application_order (lastleg_order_id), + KEY idx_ll_relabel_application_list (customer_id,status,created_at), + KEY idx_ll_relabel_application_customer (customer_id,customer_order_no), + KEY idx_ll_relabel_application_warehouse (customer_id,overseas_warehouse_id) +) ENGINE=InnoDB COMMENT='客户换标申请单'; + +CREATE TABLE IF NOT EXISTS ll_relabel_item ( + id BIGINT NOT NULL COMMENT '主键ID', + application_id BIGINT NOT NULL COMMENT '申请单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', + product_image_file_id VARCHAR(128) NULL COMMENT '产品图片文件ID', + product_image_file_name VARCHAR(255) NULL COMMENT '产品图片文件名', + product_image_url VARCHAR(1000) NULL COMMENT '产品图片地址', + product_name_zh VARCHAR(255) NOT NULL COMMENT '产品中文名称', + total_package_count INT NOT NULL COMMENT '总箱数', + notified_package_count INT NOT NULL COMMENT '已通知箱数', + shipment_no VARCHAR(128) NOT NULL COMMENT '货件编号', + tracking_code VARCHAR(255) NULL COMMENT '物流跟踪编码', + new_sku_code VARCHAR(128) NOT NULL COMMENT '换标后SKU编码', + label_requirement VARCHAR(1000) NULL COMMENT '换标要求', + relabel_file_id VARCHAR(128) NULL COMMENT '换标文件ID', + relabel_file_name VARCHAR(255) NULL COMMENT '换标文件名', + relabel_file_url VARCHAR(1000) NULL COMMENT '换标文件地址', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + KEY idx_ll_relabel_item_application (customer_id,application_id), + KEY idx_ll_relabel_item_sku (customer_id,sku_code) +) ENGINE=InnoDB COMMENT='换标货物冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_relabel_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + application_id BIGINT 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_relabel_status_history_application (customer_id,application_id,created_at) +) ENGINE=InnoDB COMMENT='换标状态变更历史'; + +CREATE TABLE IF NOT EXISTS ll_dropship_order_profile ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', + source_pallet_no VARCHAR(128) NOT NULL COMMENT '来源托盘号快照', + inventory_position_id BIGINT NULL COMMENT '库存仓位记录ID', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态', + idempotency_key VARCHAR(255) NOT 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_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no), + UNIQUE KEY uk_ll_dropship_profile_key(customer_id,idempotency_key), KEY idx_ll_dropship_profile_order(customer_id,order_id) +) ENGINE=InnoDB COMMENT='一件代发托盘档案'; + +CREATE TABLE IF NOT EXISTS ll_dropship_carton ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', + carton_sequence INT NOT NULL COMMENT '箱唛序号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态', outbound_order_no VARCHAR(128) 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_dropship_carton(customer_id,order_id,carton_mark), + KEY idx_ll_dropship_carton_status(customer_id,status), KEY idx_ll_dropship_carton_sku(customer_id,sku_code,status) +) ENGINE=InnoDB COMMENT='一件代发箱唛明细'; + +CREATE TABLE IF NOT EXISTS ll_dropship_sku_inventory ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', package_count BIGINT NOT NULL COMMENT '箱数', product_quantity BIGINT NOT 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_dropship_sku_inventory(profile_id,sku_code), + KEY idx_ll_dropship_sku_order(customer_id,order_id,sku_code) +) ENGINE=InnoDB COMMENT='一件代发SKU库存汇总'; + +CREATE TABLE IF NOT EXISTS ll_dropship_carton_ledger ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', + carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型', + before_packages BIGINT NOT NULL COMMENT '变动前箱数', delta_packages BIGINT NOT NULL COMMENT '箱数变动量', after_packages BIGINT NOT NULL COMMENT '变动后箱数', + business_order_no VARCHAR(128) NULL COMMENT '业务单号', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', remark VARCHAR(1000) NULL COMMENT '备注', + occurred_at DATETIME(3) NOT NULL COMMENT '业务发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_dropship_ledger_key(customer_id,idempotency_key), KEY idx_ll_dropship_ledger_sku(customer_id,sku_code,carton_mark) +) ENGINE=InnoDB COMMENT='一件代发箱唛库存流水'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_application ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', inbound_codes VARCHAR(2000) NOT NULL COMMENT '入仓单号集合', + total_packages INT NOT NULL COMMENT '总箱数', fee_per_package DECIMAL(18,2) NOT NULL COMMENT '每箱处理费用', + total_fee DECIMAL(18,2) NOT NULL COMMENT '费用合计', currency CHAR(3) NOT NULL DEFAULT 'USD' 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 '审核时间', 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_cargo_discard_no(customer_id,discard_no), + UNIQUE KEY uk_ll_cargo_discard_key(customer_id,idempotency_key), + KEY idx_ll_cargo_discard_list(customer_id,status,created_at), + KEY idx_ll_cargo_discard_warehouse(customer_id,overseas_warehouse_id,created_at) +) ENGINE=InnoDB COMMENT='货物废弃申请单'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_item ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', + sku_inventory_id BIGINT NOT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', + available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '废弃箱数', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code), + KEY idx_ll_cargo_discard_item_list(customer_id,application_id), + KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code) +) ENGINE=InnoDB COMMENT='货物废弃SKU明细'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id), + KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id), + KEY idx_ll_cargo_discard_carton_identity(customer_id,carton_id) +) ENGINE=InnoDB COMMENT='货物废弃锁定箱明细'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', application_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_cargo_discard_history(customer_id,application_id,created_at) +) 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 '累计入库数量', outbound_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_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', + 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_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='美国卡车订单状态历史'; 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 new file mode 100644 index 000000000..a00c65ad8 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import com.baiyue.lastleg.service.LastlegOrderService; +import com.baiyue.lastleg.service.LoadingCompletedConsumer; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class LastlegOrderControllerTest { + private final LastlegOrderService service = mock(LastlegOrderService.class); + private final LastlegOrderController controller = controller(); + private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query( + 1, 20, null, null, null, null, null, null, null, null, null, null); + + @Test + void customerCannotOverrideItsTenantScope() { + controller.list(user(100L, "MERCHANT"), "200", query); + verify(service).page(eq("100"), eq(query)); + } + + @Test + void platformOperatorMustExplicitlyChooseCustomerTenant() { + assertThrows(ResponseStatusException.class, + () -> controller.list(user(0L, "PLATFORM"), null, query)); + } + + @Test + void platformOperatorCanQueryExplicitCustomerTenant() { + controller.list(user(0L, "PLATFORM"), "200", query); + verify(service).page(eq("200"), eq(query)); + } + + private static AuthenticatedUser user(long customerId, String appCode) { + return new AuthenticatedUser(1L, customerId, customerId, 1L, "tester", appCode, "WEB", Set.of()); + } + + private LastlegOrderController controller() { + LastlegOrderController controller = new LastlegOrderController(); + ReflectionTestUtils.setField(controller, "consumer", mock(LoadingCompletedConsumer.class)); + ReflectionTestUtils.setField(controller, "orderService", service); + ReflectionTestUtils.setField(controller, "tokenGuard", mock(InternalServiceTokenGuard.class)); + return controller; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java new file mode 100644 index 000000000..4dee1fd33 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DeliverySchemaContractTest { + @Test + void baselineDefinesOutboundPalletsAndDeliveryContracts() throws Exception { + String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8) + .toLowerCase(); + + assertTrue(schema.contains("create table if not exists ll_outbound_order")); + assertTrue(schema.contains("status varchar(32) not null default 'wait_outbound'")); + assertTrue(schema.contains("unique key uk_ll_outbound_idempotency (customer_id,idempotency_key)")); + assertTrue(schema.contains("unique key uk_ll_outbound_dispatch (customer_id,dispatch_order_id)")); + assertTrue(schema.contains("create table if not exists ll_outbound_pallet_item")); + assertTrue(schema.contains("unique key uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id)")); + assertTrue(schema.contains("destination_warehouse_code varchar(128) null")); + assertTrue(schema.contains("order_refs varchar(2000) null")); + assertTrue(schema.contains("inbound_code varchar(128) null")); + assertTrue(schema.contains("create table if not exists ll_delivery_order")); + assertTrue(schema.contains("status varchar(32) not null default 'wait_delivery'")); + assertTrue(schema.contains("unique key uk_ll_delivery_idempotency (customer_id,idempotency_key)")); + assertTrue(schema.contains("unique key uk_ll_delivery_outbound (customer_id,outbound_order_id)")); + } + + @Test + void deliveryPersistenceEntitiesUseSnowflakeIds() throws Exception { + for (Class type : new Class[] { OutboundOrder.class, OutboundPalletItem.class, DeliveryOrder.class }) { + TableId id = type.getDeclaredField("id").getAnnotation(TableId.class); + assertNotNull(id, type.getSimpleName()); + assertEquals(IdType.ASSIGN_ID, id.type(), type.getSimpleName()); + } + } + + @Test + void incrementalMigrationAddsTransferTablesAndIsRerunnable() throws Exception { + String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8) + .toLowerCase(); + + assertTrue(migration.contains("2026-08-17 / 014")); + assertTrue(migration.contains("create table if not exists ll_outbound_order")); + assertTrue(migration.contains("create table if not exists ll_outbound_pallet_item")); + assertTrue(migration.contains("create table if not exists ll_delivery_order")); + } +} 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 new file mode 100644 index 000000000..d02e94c48 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -0,0 +1,297 @@ +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; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DeliveryTransferServiceTest { + @Mock DispatchOrderMapper dispatchOrders; + @Mock DispatchItemMapper dispatchItems; + @Mock OutboundOrderMapper outboundOrders; + @Mock OutboundPalletItemMapper outboundPallets; + @Mock DeliveryOrderMapper deliveryOrders; + @Mock InboundPlanPalletAllocationMapper planAllocations; + @Mock InboundPlanLineMapper planLines; + @Mock LastlegOrderMapper tailOrders; + @Mock LastlegOrderStatusHistoryMapper histories; + + private DeliveryTransferService service; + + @BeforeEach + void setUp() { + service = new DeliveryTransferService(); + ReflectionTestUtils.setField(service, "dispatchOrders", dispatchOrders); + ReflectionTestUtils.setField(service, "dispatchItems", dispatchItems); + ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); + ReflectionTestUtils.setField(service, "outboundPallets", outboundPallets); + ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders); + ReflectionTestUtils.setField(service, "planAllocations", planAllocations); + ReflectionTestUtils.setField(service, "planLines", planLines); + ReflectionTestUtils.setField(service, "tailOrders", tailOrders); + ReflectionTestUtils.setField(service, "histories", histories); + } + + @Test + void firstTransferCreatesOneOutboundOneDeliveryAndAllPalletSnapshots() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( + item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12), + item(202L, 502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8) + )); + + DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1", + new DeliveryRequests.Transfer("transfer-101", "ready")); + + ArgumentCaptor outbound = ArgumentCaptor.forClass(OutboundOrder.class); + ArgumentCaptor delivery = ArgumentCaptor.forClass(DeliveryOrder.class); + ArgumentCaptor pallets = ArgumentCaptor.forClass(OutboundPalletItem.class); + verify(outboundOrders).insert(outbound.capture()); + verify(deliveryOrders).insert(delivery.capture()); + verify(outboundPallets, org.mockito.Mockito.times(2)).insert(pallets.capture()); + + assertEquals("WAIT_OUTBOUND", outbound.getValue().getStatus()); + assertEquals(2, outbound.getValue().getPalletCount()); + assertEquals(20, outbound.getValue().getPackageCount()); + assertEquals("transfer-101", outbound.getValue().getIdempotencyKey()); + assertTrue(outbound.getValue().getOutboundNo().startsWith("OUT")); + assertEquals(outbound.getValue().getId(), delivery.getValue().getOutboundOrderId()); + assertEquals("WAIT_DELIVERY", delivery.getValue().getStatus()); + assertTrue(delivery.getValue().getDeliveryNo().startsWith("DEL")); + assertEquals(List.of("ONT8", "LAX9"), pallets.getAllValues().stream() + .map(OutboundPalletItem::getDestinationWarehouseCode).toList()); + assertEquals(List.of("IN-A", "IN-B"), pallets.getAllValues().stream() + .map(OutboundPalletItem::getInboundCode).toList()); + assertEquals(20, result.packageCount()); + assertEquals(2, result.palletCount()); + assertTrue(!result.replayed()); + } + + @Test + void sameIdempotencyKeyReturnsExistingDocumentsWithoutWrites() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); + DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(outbound); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + + DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1", + new DeliveryRequests.Transfer(" transfer-101 ", null)); + + assertTrue(result.replayed()); + assertEquals("OUT-301", result.outboundNo()); + assertEquals("DEL-401", result.deliveryNo()); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + verify(outboundPallets, never()).insert((OutboundPalletItem) any()); + verify(deliveryOrders, never()).insert((DeliveryOrder) any()); + } + + @Test + void idempotencyKeyCannotBeReusedForAnotherDispatch() { + DispatchOrder dispatch = dispatch(102L, "DSP-102", "TENANT-A"); + OutboundOrder other = outbound(301L, 101L, "DSP-101", "OUT-301", "shared-key"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-102")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 102L)).thenReturn(dispatch); + when(outboundOrders.findByIdempotency("TENANT-A", "shared-key")).thenReturn(other); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.transfer("TENANT-A", "DSP-102", "U-1", new DeliveryRequests.Transfer("shared-key", null))); + + assertEquals(409, exception.getStatusCode().value()); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + } + + @Test + void concurrentDuplicateInsertReturnsWinningDocuments() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + OutboundOrder winner = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); + DeliveryOrder delivery = delivery(401L, winner, "DEL-401"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( + item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12))); + when(outboundOrders.insert(any(OutboundOrder.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate")); + when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(null, winner); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + + DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1", + new DeliveryRequests.Transfer("transfer-101", null)); + + assertTrue(result.replayed()); + assertEquals("OUT-301", result.outboundNo()); + assertEquals("DEL-401", result.deliveryNo()); + verify(outboundPallets, never()).insert((OutboundPalletItem) any()); + verify(deliveryOrders, never()).insert((DeliveryOrder) any()); + } + + @Test + void directDispatchOrderReferenceIsBoundToCreatedDocuments() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + DispatchItem source = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12); + source.setOrderId(901L); + LastlegOrder tailOrder = new LastlegOrder(); + tailOrder.setId(901L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus("PENDING_ASSIGNMENT"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(source)); + when(tailOrders.selectList(any())).thenReturn(List.of(tailOrder)); + + service.transfer("TENANT-A", "DSP-101", "U-1", new DeliveryRequests.Transfer("direct-order", null)); + + assertEquals("WAIT_OUTBOUND", tailOrder.getCurrentStatus()); + assertNotNull(tailOrder.getOutboundOrderNo()); + assertNotNull(tailOrder.getDeliveryOrderNo()); + verify(tailOrders).updateById(tailOrder); + verify(histories).insert(any(LastlegOrderStatusHistory.class)); + } + + @Test + void transferRejectsAPlannedPalletThatHasNotBeenReceived() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + DispatchItem planned = item(201L, null, "PLAN-1", "ONT8", "Ontario", "IN-A", 12); + planned.setSourceStage("PLAN"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(planned)); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.transfer("TENANT-A", "DSP-101", "U-1", new DeliveryRequests.Transfer("transfer-101", null))); + + assertEquals(409, exception.getStatusCode().value()); + assertTrue(exception.getReason().contains("received")); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + } + + @Test + void outboundDetailReturnsPagedPalletDestinationPackagesAndInboundCode() { + 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()); + } + + @Test + void detailLookupCannotCrossTenantBoundary() { + when(outboundOrders.findByNo("TENANT-B", "OUT-301")).thenReturn(null); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.outboundDetail("TENANT-B", "OUT-301", new DeliveryRequests.PalletQuery(1, 20, null))); + + assertEquals(404, exception.getStatusCode().value()); + verify(outboundOrders).findByNo("TENANT-B", "OUT-301"); + verify(deliveryOrders, never()).lockByOutbound(any(), any()); + verify(outboundPallets, never()).selectPage(any(), any()); + } + + private static DispatchOrder dispatch(Long id, String dispatchNo, String customerId) { + DispatchOrder value = new DispatchOrder(); + value.setId(id); value.setCustomerId(customerId); value.setDispatchNo(dispatchNo); + value.setOverseasWarehouseId("WH-US-1"); value.setStatus("READY_TO_DEPART"); + return value; + } + + private static DispatchItem item(Long id, Long warehousePalletId, String palletNo, String destinationCode, + String destinationName, String inboundCode, int packages) { + DispatchItem value = new DispatchItem(); + value.setId(id); value.setWarehousePalletId(warehousePalletId); value.setPalletNo(palletNo); + value.setDestinationWarehouseId("DEST-" + destinationCode); + value.setDestinationWarehouseCode(destinationCode); value.setDestinationWarehouseName(destinationName); + value.setInboundCode(inboundCode); value.setOrderRefs("LMO-1"); value.setPackageCount(packages); + value.setSourceStage("RECEIVED"); + return value; + } + + private static OutboundOrder outbound(Long id, Long dispatchId, String dispatchNo, String outboundNo, + String idempotencyKey) { + OutboundOrder value = new OutboundOrder(); + value.setId(id); value.setCustomerId("TENANT-A"); value.setDispatchOrderId(dispatchId); + value.setDispatchNo(dispatchNo); value.setOutboundNo(outboundNo); value.setStatus("WAIT_OUTBOUND"); + value.setOverseasWarehouseId("WH-US-1"); value.setPalletCount(2); value.setPackageCount(20); + value.setIdempotencyKey(idempotencyKey); value.setCreatedAt(LocalDateTime.now()); + return value; + } + + private static DeliveryOrder delivery(Long id, OutboundOrder outbound, String deliveryNo) { + DeliveryOrder value = new DeliveryOrder(); + value.setId(id); value.setCustomerId(outbound.getCustomerId()); value.setDeliveryNo(deliveryNo); + value.setOutboundOrderId(outbound.getId()); value.setOutboundNo(outbound.getOutboundNo()); + value.setDispatchOrderId(outbound.getDispatchOrderId()); value.setDispatchNo(outbound.getDispatchNo()); + value.setOverseasWarehouseId(outbound.getOverseasWarehouseId()); value.setStatus("WAIT_DELIVERY"); + value.setPalletCount(outbound.getPalletCount()); value.setPackageCount(outbound.getPackageCount()); + value.setCreatedAt(LocalDateTime.now()); + return value; + } + + private static OutboundPalletItem pallet(Long warehousePalletId, String palletNo, String destinationCode, + String destinationName, String inboundCode, int packages) { + OutboundPalletItem value = new OutboundPalletItem(); + value.setId(warehousePalletId + 1000); value.setCustomerId("TENANT-A"); value.setOutboundOrderId(301L); + value.setWarehousePalletId(warehousePalletId); value.setPalletNo(palletNo); + value.setDestinationWarehouseId("DEST-" + destinationCode); + value.setDestinationWarehouseCode(destinationCode); value.setDestinationWarehouseName(destinationName); + value.setInboundCode(inboundCode); value.setOrderRefs("LMO-1"); value.setPackageCount(packages); + return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardMysqlAcceptanceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardMysqlAcceptanceTest.java new file mode 100644 index 000000000..58883d2ea --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardMysqlAcceptanceTest.java @@ -0,0 +1,164 @@ +package com.baiyue.lastleg.discard; + +import com.baiyue.lastleg.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.service.CargoDiscardInventoryAdjustmentService; +import com.baiyue.lastleg.discard.service.CargoDiscardService; +import org.mybatis.spring.annotation.MapperScan; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(classes = CargoDiscardMysqlAcceptanceTest.AcceptanceApplication.class, properties = { + "spring.sql.init.mode=never", + "spring.cloud.nacos.discovery.enabled=false", + "spring.cloud.nacos.discovery.register-enabled=false" +}) +@EnabledIfSystemProperty(named = "cargo.discard.acceptance", matches = "true") +class CargoDiscardMysqlAcceptanceTest { + private static final String CUSTOMER = "990001"; + + @Autowired private JdbcTemplate jdbc; + @Autowired private CargoDiscardService service; + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import({CargoDiscardService.class, CargoDiscardInventoryAdjustmentService.class}) + @MapperScan({ + "com.baiyue.lastleg.mapper", + "com.baiyue.lastleg.discard.mapper", + "com.baiyue.lastleg.dropship.mapper" + }) + static class AcceptanceApplication { + } + + @Test + void createApprovalRejectionPendingAndInsufficientStockScenarios() { + resetAcceptanceTenant(); + seedOrder(990001001L, "RK-ACCEPT-APPROVE", "JF-ACCEPT-001", 20); + seedProfile(990001101L, 990001001L, "PALLET-A"); + seedSku(990001201L, 990001001L, 990001101L, "SKU-BOX-RED", "红色折叠收纳箱", 12); + seedSku(990001202L, 990001001L, 990001101L, "SKU-RACK-KITCHEN", "厨房置物架", 8); + seedCartons(990001001L, 990001101L, 990001301L, "PALLET-A", "SKU-BOX-RED", "RED", 12); + seedCartons(990001001L, 990001101L, 990001401L, "PALLET-A", "SKU-RACK-KITCHEN", "RACK", 8); + + seedOrder(990001002L, "RK-ACCEPT-REJECT", "JF-ACCEPT-002", 10); + seedProfile(990001102L, 990001002L, "PALLET-B"); + seedSku(990001203L, 990001002L, 990001102L, "SKU-LAMP-WHITE", "白色台灯", 10); + seedCartons(990001002L, 990001102L, 990001501L, "PALLET-B", "SKU-LAMP-WHITE", "LAMP", 10); + + seedOrder(990001003L, "RK-ACCEPT-PENDING", "JF-ACCEPT-003", 7); + seedProfile(990001103L, 990001003L, "PALLET-C"); + seedSku(990001204L, 990001003L, 990001103L, "SKU-MAT-BLUE", "蓝色瑜伽垫", 7); + seedCartons(990001003L, 990001103L, 990001601L, "PALLET-C", "SKU-MAT-BLUE", "MAT", 7); + + var approveRequest = new CargoDiscardRequests.Create(List.of( + new CargoDiscardRequests.Item(990001001L, "SKU-BOX-RED", 3), + new CargoDiscardRequests.Item(990001001L, "SKU-RACK-KITCHEN", 2)), + "ACCEPT-DISCARD-APPROVE", "验收:破损货物,同意废弃"); + var approved = service.create(CUSTOMER, "acceptance-creator", approveRequest); + assertEquals(20L, skuPackages(990001001L)); + approved = service.review(CUSTOMER, approved.summary().discardNo(), "acceptance-reviewer", + new CargoDiscardRequests.Review("APPROVE", "仓库复核破损属实")); + assertEquals(CargoDiscardService.APPROVED, approved.summary().status()); + assertEquals(15L, skuPackages(990001001L)); + + var rejected = service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create( + List.of(new CargoDiscardRequests.Item(990001002L, "SKU-LAMP-WHITE", 4)), + "ACCEPT-DISCARD-REJECT", "验收:外箱可修复")); + assertEquals(10L, skuPackages(990001002L)); + rejected = service.review(CUSTOMER, rejected.summary().discardNo(), "acceptance-reviewer", + new CargoDiscardRequests.Review("REJECT", "复核后无需废弃")); + assertEquals(CargoDiscardService.REJECTED, rejected.summary().status()); + assertEquals(10L, skuPackages(990001002L)); + + var pending = service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create( + List.of(new CargoDiscardRequests.Item(990001003L, "SKU-MAT-BLUE", 2)), + "ACCEPT-DISCARD-PENDING", "验收:等待仓库审核")); + assertEquals(CargoDiscardService.PENDING_REVIEW, pending.summary().status()); + assertEquals(7L, skuPackages(990001003L)); + + assertThrows(ResponseStatusException.class, () -> service.create(CUSTOMER, "acceptance-creator", + new CargoDiscardRequests.Create(List.of( + new CargoDiscardRequests.Item(990001003L, "SKU-MAT-BLUE", 99)), + "ACCEPT-DISCARD-INSUFFICIENT", "验收:库存不足应失败"))); + + assertEquals(1, count("SELECT COUNT(*) FROM ll_cargo_discard_application WHERE customer_id=? AND status='APPROVED'")); + assertEquals(1, count("SELECT COUNT(*) FROM ll_cargo_discard_application WHERE customer_id=? AND status='REJECTED'")); + assertEquals(1, count("SELECT COUNT(*) FROM ll_cargo_discard_application WHERE customer_id=? AND status='PENDING_REVIEW'")); + assertEquals(5, count("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=? AND order_id=990001001 AND status='DISCARDED'")); + assertEquals(0, count("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=? AND order_id=990001002 AND status='DISCARD_PENDING'")); + assertEquals(2, count("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=? AND order_id=990001003 AND status='DISCARD_PENDING'")); + assertEquals(5, count("SELECT COUNT(*) FROM ll_dropship_carton_ledger WHERE customer_id=? AND operation_type='DISCARD'")); + assertEquals(5, count("SELECT COUNT(DISTINCT idempotency_key) FROM ll_dropship_carton_ledger WHERE customer_id=? AND operation_type='DISCARD'")); + String audit = jdbc.queryForObject("SELECT remark FROM ll_cargo_discard_status_history WHERE customer_id=? " + + "AND to_status='APPROVED'", String.class, CUSTOMER); + assertTrue(audit.contains("inventoryBefore=20, discarded=5, inventoryAfter=15")); + } + + private void resetAcceptanceTenant() { + jdbc.update("DELETE FROM ll_dropship_carton_ledger WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_cargo_discard_status_history WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_cargo_discard_carton WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_cargo_discard_item WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_cargo_discard_application WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_dropship_carton WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_dropship_sku_inventory WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_dropship_order_profile WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_order WHERE customer_id=?", CUSTOMER); + } + + private void seedOrder(long id, String inboundCode, String orderNo, int packages) { + jdbc.update("INSERT INTO ll_order(id,order_no,customer_id,order_type,inbound_code,source_region," + + "source_inbound_id,customer_name,overseas_warehouse_id,overseas_warehouse_name,pallet_count," + + "package_count,product_quantity,current_status,source_event_id,source_loading_plan_id,source_payload_hash," + + "inbound_order_no,created_at,updated_at) VALUES(?,?,?,'ONE_CLICK_DROPSHIP',?,'ACCEPTANCE',?," + + "'废弃验收客户','WH-US-LAX','美西洛杉矶仓',1,?,?, 'WAREHOUSED',?,?,?, ?,NOW(3),NOW(3))", + id, orderNo, CUSTOMER, inboundCode, "ACCEPT-" + id, packages, packages * 10, + "ACCEPT-EVENT-" + id, "ACCEPT-PLAN-" + id, "sha256:acceptance-" + id, orderNo); + } + + private void seedProfile(long id, long orderId, String palletNo) { + jdbc.update("INSERT INTO ll_dropship_order_profile(id,customer_id,order_id,warehouse_pallet_id,pallet_no," + + "source_pallet_no,status,idempotency_key,version,created_at,updated_at) " + + "VALUES(?,?,?, ?,?,?,'RECEIVED',?,0,NOW(3),NOW(3))", + id, CUSTOMER, orderId, id, palletNo, "SRC-" + palletNo, "ACCEPT-PROFILE-" + id); + } + + private void seedSku(long id, long orderId, long profileId, String sku, String name, int packages) { + jdbc.update("INSERT INTO ll_dropship_sku_inventory(id,customer_id,order_id,profile_id,sku_code,product_name," + + "package_count,product_quantity,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,NOW(3),NOW(3))", + id, CUSTOMER, orderId, profileId, sku, name, packages, packages * 10); + } + + private void seedCartons(long orderId, long profileId, long firstId, String palletNo, + String sku, String markPrefix, int packages) { + for (int index = 1; index <= packages; index++) { + jdbc.update("INSERT INTO ll_dropship_carton(id,customer_id,order_id,profile_id,warehouse_pallet_id," + + "pallet_no,sku_code,carton_mark,carton_sequence,status,version,created_at,updated_at) " + + "VALUES(?,?,?,?,?,?,?,?,?,'AVAILABLE',0,NOW(3),NOW(3))", + firstId + index - 1, CUSTOMER, orderId, profileId, profileId, palletNo, sku, + "ACCEPT-" + markPrefix + "-" + String.format("%03d", index), index); + } + } + + private long skuPackages(long orderId) { + return jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dropship_sku_inventory WHERE customer_id=? AND order_id=?", + Long.class, CUSTOMER, orderId); + } + + private int count(String sql) { + return jdbc.queryForObject(sql, Integer.class, CUSTOMER); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardSchemaContractTest.java new file mode 100644 index 000000000..9d4a5bd77 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardSchemaContractTest.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.discard; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CargoDiscardSchemaContractTest { + @Test + void baselineContainsDiscardApplicationReservationAndHistoryTables() 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_cargo_discard_application")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_cargo_discard_item")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history")); + assertTrue(sql.contains("UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id)")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardControllerTest.java new file mode 100644 index 000000000..8dc597069 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardControllerTest.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.discard.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.service.CargoDiscardService; +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.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 CargoDiscardControllerTest { + @Mock CargoDiscardService service; + private CargoDiscardController controller; + + @BeforeEach + void setUp() { + controller = new CargoDiscardController(); + ReflectionTestUtils.setField(controller, "service", service); + } + + @Test + void reviewRequiresExplicitApprovalPermission() { + AuthenticatedUser user = user(Set.of()); + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> controller.review( + user, null, "FH-1", new CargoDiscardRequests.Review("APPROVE", null))); + assertEquals(403, error.getStatusCode().value()); + } + + @Test + void merchantTenantComesFromTrustedIdentityWhenApproving() { + AuthenticatedUser user = user(Set.of(CargoDiscardService.APPROVAL_PERMISSION)); + CargoDiscardRequests.Review request = new CargoDiscardRequests.Review("APPROVE", "ok"); + controller.review(user, "999", "FH-1", request); + verify(service).review("100", "FH-1", "7", request); + } + + private AuthenticatedUser user(Set permissions) { + return new AuthenticatedUser(7L, 100L, 100L, 8L, "reviewer", "MERCHANT", "WEB", permissions); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentServiceTest.java new file mode 100644 index 000000000..c3a86f51f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentServiceTest.java @@ -0,0 +1,108 @@ +package com.baiyue.lastleg.discard.service; + +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.entity.CargoDiscardCarton; +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CargoDiscardInventoryAdjustmentServiceTest { + @Mock CargoDiscardInventoryMapper inventory; + @Mock DropshipCartonLedgerMapper ledgers; + private CargoDiscardInventoryAdjustmentService service; + + @BeforeEach + void setUp() { + service = new CargoDiscardInventoryAdjustmentService(); + ReflectionTestUtils.setField(service, "inventory", inventory); + ReflectionTestUtils.setField(service, "cartonLedgers", ledgers); + } + + @Test + void discardRequiresExistingTransactionAndConservesPackages() throws Exception { + Transactional transactional = CargoDiscardInventoryAdjustmentService.class + .getMethod("discard", String.class, CargoDiscardApplication.class, List.class, Map.class, + String.class, LocalDateTime.class).getAnnotation(Transactional.class); + assertEquals(Propagation.MANDATORY, transactional.propagation()); + CargoDiscardApplication application = application(2); + CargoDiscardItem item = item(2); + when(inventory.lockSkuInventory("100", 301L)).thenReturn(sku(5)); + when(inventory.deductSkuInventory("100", 301L, 2)).thenReturn(1); + when(inventory.skuPackages("100", 301L)).thenReturn(3L); + when(inventory.lockCarton(eq("100"), anyLong())).thenAnswer(invocation -> carton(invocation.getArgument(1))); + when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("DISCARD_PENDING"), eq("DISCARDED"))).thenReturn(1); + + var result = service.discard("100", application, List.of(item), + Map.of(item.getId(), List.of(link(1L), link(2L))), "9", LocalDateTime.now()); + + assertEquals(5, result.beforePackages()); + assertEquals(2, result.discardedPackages()); + assertEquals(3, result.afterPackages()); + ArgumentCaptor facts = ArgumentCaptor.forClass(DropshipCartonLedger.class); + verify(ledgers, times(2)).insert(facts.capture()); + assertTrue(facts.getAllValues().stream().allMatch(value -> "DISCARD".equals(value.getOperationType()) + && value.getIdempotencyKey().startsWith("CARGO_DISCARD:401:"))); + } + + @Test + void discardRejectsAnyPackageConservationMismatch() { + CargoDiscardApplication application = application(2); + CargoDiscardItem item = item(2); + when(inventory.lockSkuInventory("100", 301L)).thenReturn(sku(5)); + when(inventory.deductSkuInventory("100", 301L, 2)).thenReturn(1); + when(inventory.skuPackages("100", 301L)).thenReturn(4L); + when(inventory.lockCarton(eq("100"), anyLong())).thenAnswer(invocation -> carton(invocation.getArgument(1))); + when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("DISCARD_PENDING"), eq("DISCARDED"))).thenReturn(1); + + assertThrows(ResponseStatusException.class, () -> service.discard("100", application, List.of(item), + Map.of(item.getId(), List.of(link(1L), link(2L))), "9", LocalDateTime.now())); + } + + private CargoDiscardApplication application(int packages) { + CargoDiscardApplication value = new CargoDiscardApplication(); + value.setId(401L); value.setDiscardNo("FH-1"); value.setTotalPackages(packages); value.setRemark("damaged"); + return value; + } + private CargoDiscardItem item(int packages) { + CargoDiscardItem value = new CargoDiscardItem(); + value.setId(501L); value.setOrderId(101L); value.setSkuInventoryId(301L); value.setSkuCode("SKU-1"); + value.setDiscardPackages(packages); return value; + } + private DropshipSkuInventory sku(long packages) { + DropshipSkuInventory value = new DropshipSkuInventory(); value.setId(301L); value.setPackageCount(packages); return value; + } + private CargoDiscardCarton link(long id) { + CargoDiscardCarton value = new CargoDiscardCarton(); value.setItemId(501L); value.setCartonId(id); return value; + } + private DropshipCarton carton(long id) { + DropshipCarton value = new DropshipCarton(); value.setId(id); value.setCartonMark("C-" + id); return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java new file mode 100644 index 000000000..ce9707e49 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java @@ -0,0 +1,180 @@ +package com.baiyue.lastleg.discard.service; + +import com.baiyue.lastleg.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.entity.CargoDiscardCarton; +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import com.baiyue.lastleg.discard.entity.CargoDiscardStatusHistory; +import com.baiyue.lastleg.discard.mapper.CargoDiscardApplicationMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardCartonMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +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 java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CargoDiscardServiceTest { + @Mock CargoDiscardApplicationMapper applications; + @Mock CargoDiscardItemMapper items; + @Mock CargoDiscardCartonMapper discardCartons; + @Mock CargoDiscardStatusHistoryMapper histories; + @Mock CargoDiscardInventoryMapper inventory; + @Mock CargoDiscardInventoryAdjustmentService adjustments; + @Mock LastlegOrderMapper orders; + + private CargoDiscardService service; + private final AtomicReference storedApplication = new AtomicReference<>(); + private final List storedItems = new ArrayList<>(); + private final List storedLinks = new ArrayList<>(); + private final List storedHistories = new ArrayList<>(); + + @BeforeEach + void setUp() { + service = new CargoDiscardService(); + ReflectionTestUtils.setField(service, "applications", applications); + ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "discardCartons", discardCartons); + ReflectionTestUtils.setField(service, "histories", histories); + ReflectionTestUtils.setField(service, "inventory", inventory); + ReflectionTestUtils.setField(service, "adjustments", adjustments); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + ReflectionTestUtils.setField(service, "feePerPackage", new BigDecimal("5.00")); + lenient().when(applications.insert(any(CargoDiscardApplication.class))).thenAnswer(invocation -> { storedApplication.set(invocation.getArgument(0)); return 1; }); + lenient().when(items.insert(any(CargoDiscardItem.class))).thenAnswer(invocation -> { storedItems.add(invocation.getArgument(0)); return 1; }); + lenient().when(discardCartons.insert(any(CargoDiscardCarton.class))).thenAnswer(invocation -> { storedLinks.add(invocation.getArgument(0)); return 1; }); + lenient().when(histories.insert(any(CargoDiscardStatusHistory.class))).thenAnswer(invocation -> { storedHistories.add(invocation.getArgument(0)); return 1; }); + lenient().when(applications.findNo(any(), any())).thenAnswer(invocation -> storedApplication.get()); + lenient().when(items.listApplication(any(), anyLong())).thenAnswer(invocation -> List.copyOf(storedItems)); + lenient().when(discardCartons.listApplication(any(), anyLong())).thenAnswer(invocation -> List.copyOf(storedLinks)); + lenient().when(histories.listApplication(any(), anyLong())).thenAnswer(invocation -> List.copyOf(storedHistories)); + } + + @Test + void createReservesExactSkuCartonsWithoutDeductingInventory() { + LastlegOrder order = order(); + DropshipSkuInventory sku = skuInventory(); + when(orders.selectById(101L)).thenReturn(order); + when(inventory.countAvailable("100", 101L, "SKU-1")).thenReturn(3L); + when(inventory.lockAvailable("100", 101L, "SKU-1", 2)).thenReturn(List.of(carton(1L), carton(2L))); + when(inventory.lockSkuInventoryDimension("100", 101L, 201L, "SKU-1")).thenReturn(sku); + when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(3L); + when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("AVAILABLE"), eq("DISCARD_PENDING"))).thenReturn(1); + + var detail = service.create("100", "7", new CargoDiscardRequests.Create( + List.of(new CargoDiscardRequests.Item(101L, "SKU-1", 2)), "discard-create-1", "damaged")); + + assertEquals(CargoDiscardService.PENDING_REVIEW, detail.summary().status()); + assertEquals(2, detail.summary().totalPackages()); + assertEquals(new BigDecimal("10.00"), detail.summary().totalFee()); + assertEquals(2, storedLinks.size()); + verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt()); + } + + @Test + void approvalDeductsSkuInventoryAndWritesOneLedgerPerCarton() { + CargoDiscardApplication application = pendingApplication(); + storedApplication.set(application); + CargoDiscardItem item = item(); + storedItems.add(item); + storedLinks.add(link(item.getId(), 1L)); + storedLinks.add(link(item.getId(), 2L)); + when(applications.lockNo("100", "FH-1")).thenReturn(application); + when(adjustments.discard(eq("100"), eq(application), any(), any(), eq("9"), any())) + .thenReturn(new CargoDiscardInventoryAdjustmentService.AdjustmentResult(3, 2, 1)); + + var detail = service.review("100", "FH-1", "9", new CargoDiscardRequests.Review("APPROVE", "ok")); + + assertEquals(CargoDiscardService.APPROVED, detail.summary().status()); + verify(adjustments).discard(eq("100"), eq(application), any(), any(), eq("9"), any()); + assertEquals("ok; inventoryBefore=3, discarded=2, inventoryAfter=1", + storedHistories.get(storedHistories.size() - 1).getRemark()); + } + + @Test + void rejectionReleasesReservedCartonsWithoutDeduction() { + CargoDiscardApplication application = pendingApplication(); + storedApplication.set(application); + CargoDiscardItem item = item(); + storedItems.add(item); + storedLinks.add(link(item.getId(), 1L)); + when(applications.lockNo("100", "FH-1")).thenReturn(application); + when(inventory.changeCartonStatus("100", 1L, "DISCARD_PENDING", "AVAILABLE")).thenReturn(1); + + var detail = service.review("100", "FH-1", "9", new CargoDiscardRequests.Review("REJECT", "not damaged")); + + assertEquals(CargoDiscardService.REJECTED, detail.summary().status()); + verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt()); + verify(adjustments, never()).discard(any(), any(), any(), any(), any(), any()); + } + + private LastlegOrder order() { + LastlegOrder order = new LastlegOrder(); + order.setId(101L); order.setCustomerId("100"); order.setOrderType("ONE_CLICK_DROPSHIP"); + order.setInboundCode("RK-1"); order.setOverseasWarehouseId("WH-1"); order.setOverseasWarehouseName("LA"); + return order; + } + + private DropshipSkuInventory skuInventory() { + DropshipSkuInventory value = new DropshipSkuInventory(); + value.setId(301L); value.setCustomerId("100"); value.setOrderId(101L); value.setProfileId(201L); + value.setSkuCode("SKU-1"); value.setProductName("Box"); value.setPackageCount(3L); + return value; + } + + private DropshipCarton carton(Long id) { + DropshipCarton value = new DropshipCarton(); + value.setId(id); value.setCustomerId("100"); value.setOrderId(101L); value.setProfileId(201L); + value.setSkuCode("SKU-1"); value.setCartonMark("C-" + id); value.setStatus("DISCARD_PENDING"); + return value; + } + + private CargoDiscardApplication pendingApplication() { + CargoDiscardApplication value = new CargoDiscardApplication(); + value.setId(401L); value.setCustomerId("100"); value.setDiscardNo("FH-1"); value.setInboundCodes("RK-1"); + value.setOverseasWarehouseId("WH-1"); value.setTotalPackages(2); value.setFeePerPackage(new BigDecimal("5.00")); + value.setTotalFee(new BigDecimal("10.00")); value.setCurrency("USD"); value.setStatus(CargoDiscardService.PENDING_REVIEW); + return value; + } + + private CargoDiscardItem item() { + CargoDiscardItem value = new CargoDiscardItem(); + value.setId(501L); value.setApplicationId(401L); value.setCustomerId("100"); value.setOrderId(101L); + value.setInboundCode("RK-1"); value.setProfileId(201L); value.setSkuInventoryId(301L); + value.setSkuCode("SKU-1"); value.setAvailablePackagesSnapshot(3); value.setDiscardPackages(2); + return value; + } + + private CargoDiscardCarton link(Long itemId, Long cartonId) { + CargoDiscardCarton value = new CargoDiscardCarton(); + value.setId(600L + cartonId); value.setApplicationId(401L); value.setItemId(itemId); + value.setCustomerId("100"); value.setCartonId(cartonId); value.setCartonMark("C-" + cartonId); + return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchSchemaContractTest.java new file mode 100644 index 000000000..85548534b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchSchemaContractTest.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.dispatch; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DispatchSchemaContractTest { + @Test + void baselineDefinesDispatchHeadersItemsAndActivePalletLock() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_order")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_item")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock")); + assertTrue(sql.contains("uk_ll_dispatch_active_pallet")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation")); + assertTrue(sql.contains("active_key")); + assertTrue(sql.contains("order_refs")); + } + + @Test + void dispatchCandidatesExcludePalletsReservedByWarehouseTransfers() throws Exception { + String mapper = Files.readString(Path.of( + "src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java")); + assertTrue(mapper.contains("ll_warehouse_transfer_inventory_allocation")); + assertTrue(mapper.contains("ta.status='RESERVED'")); + assertTrue(mapper.contains("tp.pallet_id=p.warehouse_pallet_id")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java new file mode 100644 index 000000000..675bf5e58 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java @@ -0,0 +1,121 @@ +package com.baiyue.lastleg.dispatch.pda.service; + +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaOperation; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaOperationMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DispatchPdaWorkflowServiceTest { + @Mock private DispatchOrderMapper orders; + @Mock private DispatchPdaOperationMapper operations; + @Mock private OutboundOrderMapper outboundOrders; + + @Test + void pushRequiresVehiclePlateBeforeCreatingPdaTask() { + DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); + ReflectionTestUtils.setField(service, "orders", orders); + DispatchOrder order = new DispatchOrder(); + order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); order.setVehiclePlate(" "); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.push("100", "PC1", new DispatchPdaRequests.Push("push-1", null, null))); + + assertEquals(409, exception.getStatusCode().value()); + } + + @Test + void pushRequiresWarehouseSelection() { + DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); + ReflectionTestUtils.setField(service, "orders", orders); + DispatchOrder order = new DispatchOrder(); + order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); + order.setStatus("PENDING_DEPARTURE"); order.setVehiclePlate("CA-100"); + order.setOverseasWarehouseId("WH-1"); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.push("100", "PC1", new DispatchPdaRequests.Push("push-1", null, null))); + + assertEquals(400, exception.getStatusCode().value()); + } + + @Test + void departRequiresVehiclePlate() { + DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "operations", operations); + DispatchOrder order = new DispatchOrder(); + order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); + order.setStatus("READY_TO_DEPART"); order.setVehiclePlate(" "); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + when(operations.findKey("100", "depart-1")).thenReturn(null); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.depart("100", "PC1", "operator", new DispatchPdaRequests.Depart("depart-1", null))); + + assertEquals(409, exception.getStatusCode().value()); + } + + @Test + void departRequiresPendingOutboundOrder() { + DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "operations", operations); + ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); + DispatchOrder order = new DispatchOrder(); + order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); + order.setStatus("READY_TO_DEPART"); order.setVehiclePlate("CA-100"); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + when(operations.findKey("100", "depart-1")).thenReturn(null); + when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(null); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.depart("100", "PC1", "operator", new DispatchPdaRequests.Depart("depart-1", null))); + + assertEquals(409, exception.getStatusCode().value()); + } + + @Test + void departedRequestReplaysAfterStatusChanged() throws Exception { + DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); + ObjectMapper objectMapper = new ObjectMapper(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "operations", operations); + ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + DispatchOrder order = new DispatchOrder(); + order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); order.setStatus("DEPARTED"); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + DispatchPdaOperation operation = new DispatchPdaOperation(); + operation.setOperationType("DEPART"); operation.setStatus("COMPLETED"); + operation.setResultJson(objectMapper.writeValueAsString(new DispatchPdaResponses.Departed("PC1", "DEPARTED", 12, false))); + when(operations.findKey("100", "depart-1")).thenReturn(operation); + + // This exact key/hash pair models the persisted first execution result. + DispatchPdaRequests.Depart request = new DispatchPdaRequests.Depart("depart-1", null); + operation.setRequestHash(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(objectMapper.writeValueAsString(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + DispatchPdaResponses.Departed result = service.depart("100", "PC1", "operator", request); + + assertEquals("DEPARTED", result.status()); + assertEquals(true, result.replayed()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java new file mode 100644 index 000000000..ad9dcee13 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java @@ -0,0 +1,102 @@ +package com.baiyue.lastleg.dispatch.pda.service; + +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPickOrder; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.ArrayList; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DispatchPickOrderAttributionServiceTest { + @Mock private DispatchPdaPickOrderMapper attributions; + @Mock private LastlegOrderMapper orders; + @Mock private LastlegOrderStatusHistoryMapper history; + + @Test + void completionStatusIsCalculatedBeforeMysqlMutatesShippedPackages() throws NoSuchMethodException { + Update update = LastlegOrderMapper.class.getMethod("transferPickedToShipped", + String.class, Long.class, long.class, String.class).getAnnotation(Update.class); + String sql = String.join("", Arrays.asList(update.value())).replaceAll("\\s+", " "); + + assertTrue(sql.indexOf("current_status=CASE") + < sql.indexOf("shipped_package_count=shipped_package_count+")); + } + + @Test + void attributesPackagesAcrossInboundOrdersInReceiptFifoOrder() { + DispatchPickOrderAttributionService service = service(); + InboundStockReceipt first = receipt(11L, 101L, 3L, 30L); + InboundStockReceipt second = receipt(12L, 102L, 4L, 40L); + when(attributions.availableReceipts("t1", 91L)).thenReturn(List.of(first, second)); + when(attributions.attributedPackages(eq("t1"), anyLong())).thenReturn(0L); + when(orders.increasePicked(eq("t1"), anyLong(), anyLong())).thenReturn(1); + DispatchPdaPick pick = pick(81L, 91L, 5L, 50L); + + List result = service.attribute("t1", pick); + + assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 3L), + new DispatchPickOrderAttributionService.OrderPackages(102L, 2L)), result); + } + + @Test + void departureMovesOnlyAttributedPackagesAndWritesOrderHistory() { + DispatchPickOrderAttributionService service = service(); + DispatchPdaPick pick = pick(81L, 91L, 4L, 40L); + DispatchPdaPickOrder row = new DispatchPdaPickOrder(); + row.setOrderId(101L); row.setPackages(4L); + when(attributions.listPick("t1", 81L)).thenReturn(List.of(row)); + LastlegOrder order = new LastlegOrder(); + order.setId(101L); order.setPackageCount(10); order.setPickedPackageCount(4); + order.setShippedPackageCount(2); order.setCurrentStatus("INBOUND_COMPLETED"); + when(orders.lockById("t1", 101L)).thenReturn(order); + when(orders.transferPickedToShipped("t1", 101L, 4L, "OUT1")).thenReturn(1); + + List result = service.depart( + "t1", "OUT1", "D1", List.of(pick), LocalDateTime.of(2026, 8, 17, 15, 0)); + + assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 4L)), result); + verify(history).insert(argThat((LastlegOrderStatusHistory value) -> "PARTIALLY_SHIPPED".equals(value.getToStatus()) + && value.getRemark().contains("6/10"))); + } + + private DispatchPickOrderAttributionService service() { + DispatchPickOrderAttributionService service = new DispatchPickOrderAttributionService(); + ReflectionTestUtils.setField(service, "attributions", attributions); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "history", history); + return service; + } + + private static InboundStockReceipt receipt(long id, long orderId, long packages, long quantity) { + InboundStockReceipt receipt = new InboundStockReceipt(); + receipt.setId(id); receipt.setOrderId(orderId); receipt.setReceivedPackages(packages); + return receipt; + } + + private static DispatchPdaPick pick(long id, long positionId, long packages, long quantity) { + DispatchPdaPick pick = new DispatchPdaPick(); + pick.setId(id); pick.setInventoryPositionId(positionId); pick.setPackages(packages); + pick.setProductQuantity(quantity); pick.setPickedAt(LocalDateTime.of(2026, 8, 17, 14, 0)); + return pick; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java new file mode 100644 index 000000000..5991a2d7b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -0,0 +1,112 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; +import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +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; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DispatchServiceTest { + @Mock DispatchOrderMapper orders; + @Mock DispatchItemMapper items; + @Mock DispatchPalletLockMapper locks; + @Mock DispatchCandidateMapper candidates; + private DispatchService service; + + @BeforeEach + void setUp() { + service = new DispatchService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "locks", locks); + ReflectionTestUtils.setField(service, "candidates", candidates); + lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of()); + } + + @Test + void preArrangeLocksPalletWithoutVehicleCapacityValidation() throws Exception { + assertNotNull(DispatchService.class.getMethod("preArrange", String.class, String.class, + DispatchRequests.PreArrange.class).getAnnotation(Transactional.class)); + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); + + var result = service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, + null, null, null)); + + assertEquals(DispatchService.PENDING_DEPARTURE, result.status()); + assertEquals("ONT8", result.route()); + ArgumentCaptor dispatch = ArgumentCaptor.forClass(DispatchOrder.class); + verify(orders).insert(dispatch.capture()); + assertEquals(1, dispatch.getValue().getPalletCount()); + verify(locks).insert(any(DispatchPalletLock.class)); + verify(items).insert(any(DispatchItem.class)); + } + + @Test + void rejectsPalletAlreadyLockedByAnotherDispatch() { + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); + DispatchPalletLock locked = new DispatchPalletLock(); + when(locks.lockActive(eq("100"), eq("PLAN:10"))).thenReturn(locked); + + assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "7", + new DispatchRequests.PreArrange(List.of(10L), null, null, null, null))); + verify(orders, never()).insert(any(DispatchOrder.class)); + } + + @Test + void invalidCandidateStageIsRejected() { + assertThrows(ResponseStatusException.class, () -> service.candidates("100", null, null, "INVALID")); + verify(candidates, never()).available(any(), any(), any(), any()); + } + + @Test + void editCannotBypassPdaManagedDispatchStatus() { + DispatchOrder dispatch = new DispatchOrder(); + dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1"); + dispatch.setStatus(DispatchService.PENDING_DEPARTURE); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(dispatch); + when(orders.lockById("100", 1L)).thenReturn(dispatch); + DispatchRequests.Update request = new DispatchRequests.Update(null, null, null, null, null, + LocalDateTime.now(), null, null, null, null, "COMPLETED"); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.update("100", "PC1", request)); + + assertEquals(400, exception.getStatusCode().value()); + assertEquals(DispatchService.PENDING_DEPARTURE, dispatch.getStatus()); + verify(orders, never()).updateById(any(DispatchOrder.class)); + } + + private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) { + return new DispatchPalletCandidate(1L, planPalletId, warehousePalletId, 2L, "WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1", + "LIP1-P001", destination, destination, destination, destination, 12, + new BigDecimal("1.2000"), warehousePalletId == null ? "PLAN" : "RECEIVED"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/DropshipSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/DropshipSchemaContractTest.java new file mode 100644 index 000000000..adf1944e5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/DropshipSchemaContractTest.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.dropship; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class DropshipSchemaContractTest { + @Test + void baselineAndIncrementalSqlKeepContainerAndMultiPalletIndexes() throws Exception { + String baseline = Files.readString( + Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8).toLowerCase(); + String migration = Files.readString( + Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8).toLowerCase(); + + assertThat(baseline).contains( + "key idx_ll_order_container(customer_id,container_no)", + "unique key uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no)", + "unique key uk_ll_dropship_sku_inventory(profile_id,sku_code)", + "key idx_ll_dropship_profile_order(customer_id,order_id)", + "warehouse_pallet_id bigint null comment '仓库实物托盘id'", + "pallet_no varchar(128) null comment '托盘号'", + "status varchar(32) not null default 'pending_inbound'"); + assertThat(migration).contains( + "add key idx_ll_order_container (customer_id,container_no)", + "add unique key uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no)", + "add unique key uk_ll_dropship_sku_profile(profile_id,sku_code)", + "add key idx_ll_dropship_profile_order(customer_id,order_id)", + "modify column warehouse_pallet_id bigint null", + "modify column pallet_no varchar(128) null"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java new file mode 100644 index 000000000..85564aa1f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java @@ -0,0 +1,148 @@ +package com.baiyue.lastleg.dropship.service; + +import com.baiyue.lastleg.dropship.dto.DropshipRequests; +import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper; +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.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DropshipOrderServiceTest { + @Mock private LastlegOrderMapper orders; + @Mock private DropshipOrderProfileMapper profiles; + @Mock private DropshipSkuInventoryMapper skuInventory; + @Mock private DropshipCartonMapper cartons; + @Mock private DropshipCartonLedgerMapper ledger; + @Mock private WarehouseResourceGateway warehouse; + @InjectMocks private DropshipOrderService service; + + @Test + void receivesMultiplePalletsForOneInboundOrder() { + LastlegOrder order = new LastlegOrder(); + order.setId(10L); + order.setCustomerId("C-1"); + order.setOrderType("ONE_CLICK_DROPSHIP"); + order.setOrderNo("LL-10"); + order.setPackageCount(3); + order.setProductQuantity(30); + when(orders.selectById(10L)).thenReturn(order); + when(profiles.listOrder("C-1", 10L)).thenReturn(List.of()); + + DropshipRequests.Receive request = new DropshipRequests.Receive("C-1", 10L, 20L, "EVENT-1", + List.of( + new DropshipRequests.Pallet("SOURCE-P-1", List.of( + new DropshipRequests.SkuPackages("SKU-A", "Product A", 2, 20, + List.of("MARK-1", "MARK-2")))), + new DropshipRequests.Pallet("SOURCE-P-2", List.of( + new DropshipRequests.SkuPackages("SKU-A", "Product A", 1, 10, + List.of("MARK-3")))))); + + assertThat(service.receive(request)).isEqualTo(3); + verifyNoInteractions(warehouse); + verify(profiles, times(2)).insert(any(DropshipOrderProfile.class)); + verify(skuInventory, times(2)).insert(any(DropshipSkuInventory.class)); + verify(cartons, times(3)).insert(any(DropshipCarton.class)); + verify(ledger, times(3)).insert(any(DropshipCartonLedger.class)); + + ArgumentCaptor profilesCaptor = ArgumentCaptor.forClass(DropshipOrderProfile.class); + verify(profiles, times(2)).insert(profilesCaptor.capture()); + assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getSourcePalletNo) + .containsExactly("SOURCE-P-1", "SOURCE-P-2"); + assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getCustomerId) + .containsOnly("C-1"); + assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getStatus) + .containsOnly("PENDING_INBOUND"); + assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getWarehousePalletId) + .containsOnlyNulls(); + ArgumentCaptor cartonCaptor = ArgumentCaptor.forClass(DropshipCarton.class); + verify(cartons, times(3)).insert(cartonCaptor.capture()); + assertThat(cartonCaptor.getAllValues()).extracting(DropshipCarton::getStatus) + .containsOnly("PENDING_INBOUND"); + ArgumentCaptor ledgerCaptor = ArgumentCaptor.forClass(DropshipCartonLedger.class); + verify(ledger, times(3)).insert(ledgerCaptor.capture()); + assertThat(ledgerCaptor.getAllValues()).extracting(DropshipCartonLedger::getOperationType) + .containsOnly("SNAPSHOT_CREATED"); + assertThat(ledgerCaptor.getAllValues()).extracting(DropshipCartonLedger::getDeltaPackages) + .containsOnly(0L); + } + + @Test + void activatesMultiplePalletsOnlyAfterPdaStockIn() { + DropshipOrderProfile first = profile(1L, "SOURCE-P-1"); + DropshipOrderProfile second = profile(2L, "SOURCE-P-2"); + when(profiles.listOrder("C-1", 10L)).thenReturn(List.of(first, second)); + DropshipCarton firstCarton = carton(11L, 1L, "MARK-1"); + DropshipCarton secondCarton = carton(12L, 2L, "MARK-2"); + when(cartons.listProfile("C-1", 1L)).thenReturn(List.of(firstCarton)); + when(cartons.listProfile("C-1", 2L)).thenReturn(List.of(secondCarton)); + + service.activateAfterStockIn("C-1", 10L, 50L, List.of( + receipt(101L, "WH-P-1", 201L, 1), receipt(102L, "WH-P-2", 202L, 1))); + + assertThat(first.getStatus()).isEqualTo("ACTIVE"); + assertThat(first.getWarehousePalletId()).isEqualTo(101L); + assertThat(firstCarton.getStatus()).isEqualTo("AVAILABLE"); + assertThat(firstCarton.getWarehousePalletId()).isEqualTo(101L); + verify(profiles, times(2)).updateById(any(DropshipOrderProfile.class)); + verify(cartons, times(2)).updateById(any(DropshipCarton.class)); + ArgumentCaptor facts = ArgumentCaptor.forClass(DropshipCartonLedger.class); + verify(ledger, times(2)).insert(facts.capture()); + assertThat(facts.getAllValues()).extracting(DropshipCartonLedger::getOperationType).containsOnly("STOCK_IN"); + assertThat(facts.getAllValues()).extracting(DropshipCartonLedger::getDeltaPackages).containsOnly(1L); + } + + @Test + void repeatedPdaActivationIsANoOp() { + DropshipOrderProfile active = profile(1L, "SOURCE-P-1"); + active.setStatus("ACTIVE"); + when(profiles.listOrder("C-1", 10L)).thenReturn(List.of(active)); + + service.activateAfterStockIn("C-1", 10L, 50L, + List.of(receipt(101L, "WH-P-1", 201L, 1))); + + verify(profiles, never()).updateById(any(DropshipOrderProfile.class)); + verify(cartons, never()).updateById(any(DropshipCarton.class)); + verify(ledger, never()).insert(any(DropshipCartonLedger.class)); + } + + private static DropshipOrderProfile profile(long id, String sourcePalletNo) { + DropshipOrderProfile profile = new DropshipOrderProfile(); + profile.setId(id); profile.setCustomerId("C-1"); profile.setOrderId(10L); + profile.setSourcePalletNo(sourcePalletNo); profile.setStatus("PENDING_INBOUND"); profile.setVersion(0L); + return profile; + } + + private static DropshipCarton carton(long id, long profileId, String mark) { + DropshipCarton carton = new DropshipCarton(); + carton.setId(id); carton.setCustomerId("C-1"); carton.setOrderId(10L); carton.setProfileId(profileId); + carton.setCartonMark(mark); carton.setSkuCode("SKU-A"); carton.setStatus("PENDING_INBOUND"); carton.setVersion(0L); + return carton; + } + + private static InboundStockReceipt receipt(long palletId, String palletNo, long positionId, long packages) { + InboundStockReceipt receipt = new InboundStockReceipt(); + receipt.setOrderId(10L); receipt.setWarehousePalletId(palletId); receipt.setPalletNo(palletNo); + receipt.setInventoryPositionId(positionId); receipt.setReceivedPackages(packages); + return receipt; + } +} 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 new file mode 100644 index 000000000..32b31ef38 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.inboundplan; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InboundPlanSchemaContractTest { + @Test + void planIsUniqueByTenantAndActualContainerNumber() 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("default 'pending_assignment'")); + assertTrue(schema.contains("required_package_count")); + assertTrue(schema.contains("planned_package_count")); + assertTrue(schema.contains("warehouse_resource_lock_id")); + assertTrue(schema.contains("create table if not exists ll_inbound_pda_task")); + assertTrue(schema.contains("create table if not exists ll_inbound_pda_operation")); + assertTrue(schema.contains("create table if not exists ll_inbound_pallet_merge")); + assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment")); + assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (customer_id, appointment_no)")); + assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment_item")); + assertTrue(schema.contains("fba_warehouse_code")); + assertFalse(schema.contains("cabinet_no")); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java new file mode 100644 index 000000000..1e2c79ed6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java @@ -0,0 +1,374 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundPdaWorkflowServiceTest { + @Mock PdaWorkflowRepository repository; + @Mock WarehouseResourceGateway warehouse; + @Mock InboundStockReceiptService stockReceipts; + private InboundPdaWorkflowService service; + + private InboundPlan plan; + private InboundPdaTask task; + private InboundPlanLine targetLine; + private InboundPlanLine sourceLine; + private InboundPlanPallet targetPallet; + private InboundPlanPallet sourcePallet; + private InboundPlanPallet otherWarehousePallet; + private InboundPlanPalletAllocation target; + private InboundPlanPalletAllocation source; + private InboundPlanPalletAllocation otherWarehouse; + + @BeforeEach + void setUp() { + service = new InboundPdaWorkflowService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "warehouse", warehouse); + ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); + ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules()); + service.initializeObjectMapper(); + plan = plan(); + task = task(); + targetLine = line(11L, 101L, "RK-A", "1.0000", 5); + sourceLine = line(12L, 102L, "RK-B", "0.5000", 2); + targetPallet = pallet(21L, "WH-A", 3, "0.6000", 901L); + sourcePallet = pallet(22L, "WH-A", 2, "0.5000", 902L); + otherWarehousePallet = pallet(23L, "WH-B", 4, "0.8000", 903L); + target = allocation(31L, targetLine.getId(), targetPallet.getId(), 3, 3, 0); + source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2); + otherWarehouse = allocation(33L, sourceLine.getId(), otherWarehousePallet.getId(), 2, 2, 2); + when(repository.lockPlan("100", "LIP1")).thenReturn(plan); + } + + @Test + void pushRequiresEveryPalletToBeAssignedAndLocked() { + targetPallet.setWarehouseResourceLockId(null); + when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet)); + + assertThrows(ResponseStatusException.class, + () -> service.push("100", "LIP1", new PdaRequests.Push("push-1", "7", "worker"))); + + verify(repository, never()).insert(any(InboundPdaTask.class)); + } + + @Test + void assignmentReservesWarehouseResourcesAndPersistsTheLock() { + targetPallet.setDestinationWarehouseId("88"); + targetPallet.setWarehousePalletId(null); + targetPallet.setWarehouseLocationId(null); + targetPallet.setWarehouseResourceLockId(null); + targetPallet.setAssignmentStatus("PENDING_ASSIGNMENT"); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + when(warehouse.reserve("100", 88L, 188L, 288L, "INBOUND_PLAN_PALLET", "LIP1:21", "assign-1")) + .thenReturn(999L); + when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet)); + + service.assign("100", "LIP1", targetPallet.getId(), + new PdaRequests.Assign(88L, 188L, 288L, "assign-1")); + + assertEquals(188L, targetPallet.getWarehousePalletId()); + assertEquals(288L, targetPallet.getWarehouseLocationId()); + assertEquals(999L, targetPallet.getWarehouseResourceLockId()); + assertEquals("ASSIGNED", targetPallet.getAssignmentStatus()); + assertEquals("ASSIGNED", plan.getStatus()); + verify(repository).update(targetPallet); + } + + @Test + void repeatedAssignmentIsIdempotentAndWrongDestinationIsRejected() { + targetPallet.setDestinationWarehouseId("88"); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + + service.assign("100", "LIP1", targetPallet.getId(), + new PdaRequests.Assign(88L, targetPallet.getWarehousePalletId(), + targetPallet.getWarehouseLocationId(), "assign-retry")); + + verify(warehouse, never()).reserve(any(), anyLong(), anyLong(), anyLong(), + any(), any(), any()); + assertThrows(ResponseStatusException.class, () -> service.assign("100", "LIP1", targetPallet.getId(), + new PdaRequests.Assign(99L, targetPallet.getWarehousePalletId(), + targetPallet.getWarehouseLocationId(), "assign-wrong-warehouse"))); + } + + @Test + void countMustPrecedePickAndPickedBoxesDriveActualVolume() { + activeTask(); + target.setCountedPackageCount(0); + when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); + when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + + assertThrows(ResponseStatusException.class, () -> service.pick("100", "LIP1", target.getId(), + new PdaRequests.PackageOperation(1, "pick-before-count", "7", "worker"))); + + service.count("100", "LIP1", target.getId(), + new PdaRequests.PackageOperation(3, "count-3", "7", "worker")); + var result = service.pick("100", "LIP1", target.getId(), + new PdaRequests.PackageOperation(2, "pick-2", "7", "worker")); + + assertEquals("PICKED", result.result()); + assertEquals(2, target.getPickedPackageCount()); + assertEquals("0.4000", target.getActualVolumeCbm().toPlainString()); + assertEquals(5, targetPallet.getActualPackageCount()); + assertEquals("1.0000", targetPallet.getActualVolumeCbm().toPlainString()); + } + + @Test + void overPickReturnsConfirmationWithoutChangingAllocationOrPallet() { + activeTask(); + when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); + when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); + candidateQueries(); + + var result = service.pick("100", "LIP1", target.getId(), + new PdaRequests.PackageOperation(5, "pick-over", "7", "worker")); + + assertEquals("MERGE_CONFIRMATION_REQUIRED", result.result()); + assertEquals(3, result.remainingPackageCount()); + assertEquals(2, result.mergePackageCount()); + assertEquals(1, result.candidates().size()); + assertEquals(source.getId(), result.candidates().get(0).allocationId()); + assertEquals(0, target.getPickedPackageCount()); + verify(repository, never()).update(target); + verify(repository, never()).update(targetPallet); + verify(repository).insert(any(InboundPdaOperation.class)); + } + + @Test + void mergeCandidatesOnlyIncludeSameDestinationPallets() { + when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); + candidateQueries(); + + var result = service.mergeCandidates("100", "LIP1", target.getId()); + + assertEquals(1, result.size()); + assertEquals("RK-B", result.get(0).inboundCode()); + assertEquals("0.5000", result.get(0).availableVolumeCbm().toPlainString()); + } + + @Test + void confirmedMergePersistsBothOrdersAndScrapsAnEmptiedSourcePallet() { + activeTask(); + when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); + when(repository.lockAllocation(plan.getId(), source.getId())).thenReturn(source); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + when(repository.lockPallet(plan.getId(), sourcePallet.getId())).thenReturn(sourcePallet); + when(repository.line(plan.getId(), sourceLine.getId())).thenReturn(sourceLine); + when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); + when(repository.allocations(plan.getId())).thenReturn(List.of(target, source)); + + var result = service.merge("100", "LIP1", target.getId(), + new PdaRequests.ConfirmMerge(source.getId(), 2, "merge-1", "7", "worker")); + + assertEquals("MERGED", result.result()); + assertEquals(sourceLine.getOrderId(), result.sourceOrderId()); + assertEquals(targetLine.getOrderId(), result.targetOrderId()); + assertTrue(result.sourcePalletScrapped()); + assertEquals(0, sourcePallet.getActualPackageCount()); + assertEquals(5, targetPallet.getActualPackageCount()); + verify(warehouse).scrap("100", 902L); + ArgumentCaptor merge = ArgumentCaptor.forClass(InboundPalletMerge.class); + verify(repository).insert(merge.capture()); + assertEquals(sourceLine.getOrderId(), merge.getValue().getSourceOrderId()); + assertEquals(targetLine.getOrderId(), merge.getValue().getTargetOrderId()); + assertTrue(merge.getValue().getSourcePalletScrapped()); + } + + @Test + void mergeCannotExceedTargetPalletVolumeCapacity() { + activeTask(); + targetPallet.setActualVolumeCbm(new BigDecimal("1.9000")); + when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); + when(repository.lockAllocation(plan.getId(), source.getId())).thenReturn(source); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + when(repository.lockPallet(plan.getId(), sourcePallet.getId())).thenReturn(sourcePallet); + when(repository.line(plan.getId(), sourceLine.getId())).thenReturn(sourceLine); + when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); + + assertThrows(ResponseStatusException.class, () -> service.merge("100", "LIP1", target.getId(), + new PdaRequests.ConfirmMerge(source.getId(), 1, "merge-over-capacity", "7", "worker"))); + + verify(warehouse, never()).scrap(any(), anyLong()); + verify(repository, never()).insert(any(InboundPalletMerge.class)); + } + + @Test + void completionRequiresExactPickedTotalForEveryLine() { + activeTask(); + when(repository.allocations(plan.getId())).thenReturn(List.of(target)); + when(repository.lines(plan.getId())).thenReturn(List.of(targetLine)); + + assertThrows(ResponseStatusException.class, () -> service.complete("100", "LIP1")); + assertFalse("COMPLETED".equals(task.getStatus())); + verify(repository, never()).update(plan); + } + + @Test + void completesWhenMergedAllocationsPreserveExactLineTotals() { + activeTask(); + target.setPickedPackageCount(3); + InboundPlanPalletAllocation moved = allocation(34L, targetLine.getId(), sourcePallet.getId(), 0, 2, 2); + when(repository.allocations(plan.getId())).thenReturn(List.of(target, moved)); + when(repository.lines(plan.getId())).thenReturn(List.of(targetLine)); + + var result = service.complete("100", "LIP1"); + + assertEquals("COMPLETED", result.status()); + assertEquals("COMPLETED", plan.getStatus()); + verify(repository).update(task); + verify(repository).update(plan); + verify(stockReceipts).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(targetLine)), + eq(List.of(target, moved)), any(), any()); + } + + @Test + void completedTaskReplayDoesNotRunStockInAgain() { + task.setStatus("COMPLETED"); + activeTask(); + + var result = service.complete("100", "LIP1"); + + assertEquals("COMPLETED", result.status()); + verify(stockReceipts, never()).stockIn(any(), any(), any(), any(), any(), any(), any()); + verify(repository, never()).allocations(plan.getId()); + } + + @Test + void rejectsReuseOfIdempotencyKeyWithDifferentActionOrPayload() { + InboundPdaOperation existing = new InboundPdaOperation(); + existing.setAction("COUNT"); + existing.setRequestHash("different"); + when(repository.operation("100", "same-key")).thenReturn(existing); + + assertThrows(ResponseStatusException.class, () -> service.pick("100", "LIP1", target.getId(), + new PdaRequests.PackageOperation(1, "same-key", "7", "worker"))); + } + + @Test + void completedTaskStillReplaysAnAlreadyPersistedPick() { + activeTask(); + when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); + when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + PdaRequests.PackageOperation request = new PdaRequests.PackageOperation(1, "pick-replay", "7", "worker"); + + var first = service.pick("100", "LIP1", target.getId(), request); + ArgumentCaptor operation = ArgumentCaptor.forClass(InboundPdaOperation.class); + verify(repository).insert(operation.capture()); + task.setStatus("COMPLETED"); + when(repository.operation("100", "pick-replay")).thenReturn(operation.getValue()); + + var replay = service.pick("100", "LIP1", target.getId(), request); + + assertEquals(first, replay); + assertEquals(1, target.getPickedPackageCount()); + verify(repository, times(1)).lockAllocation(plan.getId(), target.getId()); + } + + private void activeTask() { when(repository.lockTask("100", plan.getId())).thenReturn(task); } + + private void candidateQueries() { + when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet, sourcePallet, otherWarehousePallet)); + when(repository.lines(plan.getId())).thenReturn(List.of(targetLine, sourceLine)); + when(repository.allocations(plan.getId())).thenReturn(List.of(target, source, otherWarehouse)); + } + + private InboundPlan plan() { + InboundPlan value = new InboundPlan(); + value.setId(1L); + value.setCustomerId("100"); + value.setPlanNo("LIP1"); + value.setStatus("ASSIGNED"); + return value; + } + + private InboundPdaTask task() { + InboundPdaTask value = new InboundPdaTask(); + value.setId(2L); + value.setPlanId(1L); + value.setCustomerId("100"); + value.setTaskNo("PDA2"); + value.setStatus("PDA_PENDING"); + value.setVersion(0); + return value; + } + + private InboundPlanLine line(long id, long orderId, String inboundCode, String volume, int packages) { + InboundPlanLine value = new InboundPlanLine(); + value.setId(id); + value.setPlanId(1L); + value.setOrderId(orderId); + value.setInboundCode(inboundCode); + value.setDestinationWarehouseId("WH-A"); + value.setVolumeCbm(new BigDecimal(volume)); + value.setRequiredPackageCount(packages); + return value; + } + + private InboundPlanPallet pallet(long id, String warehouseId, int actualPackages, + String actualVolume, long lockId) { + InboundPlanPallet value = new InboundPlanPallet(); + value.setId(id); + value.setPlanId(1L); + value.setPalletNo("PLT-" + id); + value.setDestinationWarehouseId(warehouseId); + value.setCapacityCbm(new BigDecimal("2.0000")); + value.setWarehousePalletId(id + 1000); + value.setWarehouseLocationId(id + 2000); + value.setWarehouseResourceLockId(lockId); + value.setAssignmentStatus("ASSIGNED"); + value.setActualPackageCount(actualPackages); + value.setActualVolumeCbm(new BigDecimal(actualVolume)); + return value; + } + + private InboundPlanPalletAllocation allocation(long id, long lineId, long palletId, + int planned, int counted, int picked) { + InboundPlanPalletAllocation value = new InboundPlanPalletAllocation(); + value.setId(id); + value.setPlanId(1L); + value.setLineId(lineId); + value.setPalletId(palletId); + value.setPlannedPackageCount(planned); + value.setCountedPackageCount(counted); + value.setPickedPackageCount(picked); + value.setActualVolumeCbm(BigDecimal.ZERO); + value.setVersion(0); + return value; + } +} 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 new file mode 100644 index 000000000..5ab60faa6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -0,0 +1,214 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +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; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper; +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.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +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.LocalDateTime; +import java.util.List; + +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.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundStockReceiptServiceTest { + @Mock LastlegOrderMapper orders; + @Mock InboundStockReceiptMapper receipts; + @Mock InventoryCommandService inventory; + @Mock DropshipOrderService dropshipOrders; + @Mock LastlegOrderStatusHistoryMapper orderHistories; + private InboundStockReceiptService service; + + @BeforeEach + void setUp() { + service = new InboundStockReceiptService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "receipts", receipts); + ReflectionTestUtils.setField(service, "inventory", inventory); + ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders); + ReflectionTestUtils.setField(service, "orderHistories", orderHistories); + when(receipts.listTask("100", 2L)).thenReturn(List.of()); + } + + @Test + void writesOneReceiptPerSkuAndPalletAndUsesPdaPackageCount() { + Fixture fixture = fixture(5, 5, 5, 10); + when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); + when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class))) + .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false)); + + service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line), + List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now()); + + ArgumentCaptor request = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class); + verify(inventory).stockIn(eq("100"), eq("operator"), request.capture()); + assertEquals(5L, request.getValue().packages()); + assertEquals(101L, request.getValue().inboundOrderId()); + assertEquals("IN-1", request.getValue().inboundCode()); + ArgumentCaptor receipt = ArgumentCaptor.forClass(InboundStockReceipt.class); + verify(receipts).insert(receipt.capture()); + assertEquals(5L, receipt.getValue().getReceivedPackages()); + assertEquals(10L, receipt.getValue().getInventoryPositionId()); + assertEquals("INBOUND_COMPLETED", fixture.order.getCurrentStatus()); + assertEquals("INBOUND_PLAN:COMPLETED", fixture.order.getSubOrderStatus()); + verify(orderHistories).insert(any(LastlegOrderStatusHistory.class)); + } + + @Test + void storesOneInboundOrderAcrossMultiplePalletsWithoutSkuSplitting() { + Fixture fixture = fixture(10, 5, 5, 10); + fixture.detail.setPackageCount(5); + LastlegOrderDetail second = detail(202L, "SKU-2", 5, 7); + InboundPlanPallet secondPallet = pallet(22L, 2002L); + InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5); + when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); + when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class))) + .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false)); + + service.stockIn("100", fixture.plan, fixture.task, + List.of(fixture.line), List.of(fixture.allocation, secondAllocation), + List.of(fixture.pallet, secondPallet), LocalDateTime.now()); + verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), any()); + verify(receipts, times(2)).insert(any(InboundStockReceipt.class)); + } + + @Test + void conservesSingleSkuPackagesAndQuantityAcrossPallets() { + Fixture fixture = fixture(5, 2, 2, 11); + InboundPlanPallet secondPallet = pallet(22L, 2002L); + InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 3); + when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); + when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class))) + .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false)); + + service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line), + List.of(fixture.allocation, secondAllocation), List.of(fixture.pallet, secondPallet), LocalDateTime.now()); + + ArgumentCaptor requests = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class); + verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), requests.capture()); + assertEquals(5L, requests.getAllValues().stream().mapToLong(InventoryCommandRequests.StockIn::packages).sum()); + } + + @Test + void existingTaskReceiptMakesStockInReplayANoOp() { + Fixture fixture = fixture(10, 5, 5, 10); + InboundPlanPallet secondPallet = pallet(22L, 2002L); + InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5); + InboundStockReceipt firstReceipt = new InboundStockReceipt(); + firstReceipt.setAllocationId(fixture.allocation.getId()); firstReceipt.setOrderId(fixture.order.getId()); + InboundStockReceipt secondReceipt = new InboundStockReceipt(); + secondReceipt.setAllocationId(secondAllocation.getId()); secondReceipt.setOrderId(fixture.order.getId()); + when(receipts.listTask("100", fixture.task.getId())).thenReturn(List.of(firstReceipt, secondReceipt)); + when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); + + service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line), + List.of(fixture.allocation, secondAllocation), List.of(fixture.pallet, secondPallet), LocalDateTime.now()); + + verify(inventory, never()).stockIn(any(), any(), any()); + verify(receipts, never()).insert(any(InboundStockReceipt.class)); + } + + @Test + void partialTaskReceiptsAreRejected() { + Fixture fixture = fixture(10, 5, 5, 10); + InboundPlanPallet secondPallet = pallet(22L, 2002L); + InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5); + InboundStockReceipt partial = new InboundStockReceipt(); + partial.setAllocationId(fixture.allocation.getId()); partial.setOrderId(fixture.order.getId()); + when(receipts.listTask("100", fixture.task.getId())).thenReturn(List.of(partial)); + when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.stockIn("100", fixture.plan, fixture.task, + List.of(fixture.line), List.of(fixture.allocation, secondAllocation), + List.of(fixture.pallet, secondPallet), LocalDateTime.now())); + assertEquals(409, exception.getStatusCode().value()); + + verify(inventory, never()).stockIn(any(), any(), any()); + verify(dropshipOrders, never()).activateAfterStockIn(any(), any(), any(), any()); + } + + @Test + void activatesDropshipAfterGenericStockInReceiptIsCreated() { + Fixture fixture = fixture(5, 5, 5, 10); + fixture.order.setOrderType("ONE_CLICK_DROPSHIP"); + when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); + when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class))) + .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false)); + + service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line), + List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now()); + + ArgumentCaptor> activationReceipts = ArgumentCaptor.forClass(List.class); + verify(dropshipOrders).activateAfterStockIn(eq("100"), eq(101L), eq(2L), activationReceipts.capture()); + assertEquals(1, activationReceipts.getValue().size()); + assertEquals(10L, activationReceipts.getValue().get(0).getInventoryPositionId()); + } + + private Fixture fixture(int required, int planned, int picked, int quantity) { + InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); + InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator"); + InboundPlanLine line = new InboundPlanLine(); line.setId(12L); line.setPlanId(1L); line.setOrderId(101L); + line.setInboundCode("IN-1"); line.setDestinationWarehouseId("WH-DEST"); + line.setDestinationWarehouseCode("ONT8"); line.setDestinationWarehouseName("ONT8"); + line.setRequiredPackageCount(required); line.setVolumeCbm(new BigDecimal("1.0")); + LastlegOrder order = new LastlegOrder(); order.setId(101L); order.setCustomerId("100"); + order.setOrderNo("LMO-1"); order.setCustomerName("Customer"); order.setOverseasWarehouseId("OW-1"); + order.setOverseasWarehouseName("Warehouse"); order.setSourceEventId("evt-1"); order.setFbaWarehouseCode("ONT8"); + order.setCurrentStatus("INBOUND_PLANNED"); + org.mockito.Mockito.lenient().when(orders.lockById("100", 101L)).thenReturn(order); + LastlegOrderDetail detail = detail(201L, "SKU-1", required, quantity); + return new Fixture(plan, task, line, order, detail, allocation(31L, 12L, 21L, picked), pallet(21L, 2001L)); + } + + private LastlegOrderDetail detail(long id, String sku, int packages, int quantity) { + LastlegOrderDetail detail = new LastlegOrderDetail(); detail.setId(id); detail.setOrderId(101L); + detail.setSkuCode(sku); detail.setPackageCount(packages); detail.setProductQuantity(quantity); + detail.setProductNameZh(sku + " name"); return detail; + } + + private InboundPlanPallet pallet(long id, long warehousePalletId) { + InboundPlanPallet pallet = new InboundPlanPallet(); pallet.setId(id); pallet.setPlanId(1L); + pallet.setPalletNo("PLT-" + id); pallet.setWarehousePalletId(warehousePalletId); + pallet.setWarehouseLocationId(3001L); return pallet; + } + + private InboundPlanPalletAllocation allocation(long id, long lineId, long palletId, int picked) { + InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(id); + allocation.setPlanId(1L); allocation.setLineId(lineId); allocation.setPalletId(palletId); + allocation.setPlannedPackageCount(picked); allocation.setPickedPackageCount(picked); return allocation; + } + + private record Fixture(InboundPlan plan, InboundPdaTask task, InboundPlanLine line, LastlegOrder order, + LastlegOrderDetail detail, InboundPlanPalletAllocation allocation, + InboundPlanPallet pallet) {} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java new file mode 100644 index 000000000..0430ca248 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java @@ -0,0 +1,137 @@ +package com.baiyue.lastleg.inboundplan.service; + +import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper; +import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot; +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.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.List; + +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.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundFbaAppointmentServiceTest { + @Mock InboundFbaAppointmentMapper appointmentMapper; + @Mock InboundFbaAppointmentItemMapper itemMapper; + @Mock InboundFbaAppointmentQueryMapper queryMapper; + + private InboundFbaAppointmentService service; + private final LocalDateTime appointmentAt = LocalDateTime.of(2026, 8, 20, 10, 30); + + @BeforeEach + void setUp() { + service = new InboundFbaAppointmentService(); + ReflectionTestUtils.setField(service, "appointmentMapper", appointmentMapper); + ReflectionTestUtils.setField(service, "itemMapper", itemMapper); + ReflectionTestUtils.setField(service, "queryMapper", queryMapper); + lenient().when(itemMapper.selectList(any())).thenReturn(List.of()); + } + + @Test + void createsOneBatchForSelectedLinesAndFreezesFbaWarehouse() throws Exception { + assertEquals(true, InboundFbaAppointmentService.class + .getMethod("batchCreate", String.class, String.class, BatchFbaAppointmentRequest.class) + .getAnnotation(Transactional.class) != null); + when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of( + line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "ONT8"))); + + var result = service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1")); + + assertEquals("APT-1", result.appointmentNo()); + assertEquals("ONT8", result.fbaWarehouseCode()); + assertEquals(2, result.selectedCount()); + verify(appointmentMapper).insert(any(InboundFbaAppointment.class)); + verify(itemMapper, org.mockito.Mockito.times(2)).insert(any(InboundFbaAppointmentItem.class)); + } + + @Test + void rejectsMixedFbaWarehouses() { + when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of( + line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "LAX9"))); + + assertThrows(ResponseStatusException.class, + () -> service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1"))); + verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class)); + } + + @Test + void rejectsMissingLineFromAnotherTenant() { + when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of(line(11L, "IN-11", "ONT8"))); + + assertThrows(ResponseStatusException.class, + () -> service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1"))); + } + + @Test + void duplicateLineIdsAreRejectedBeforeDatabaseAccess() { + assertThrows(ResponseStatusException.class, + () -> service.batchCreate("100", "7", request(List.of(11L, 11L), "APT-1"))); + verify(queryMapper, never()).lockLines(any(), any()); + } + + @Test + void sameAppointmentReplayReturnsExistingResult() { + InboundFbaAppointment existing = appointment(99L, "APT-1", "ONT8"); + InboundFbaAppointmentItem item = new InboundFbaAppointmentItem(); + item.setLineId(11L); + item.setInboundCode("IN-11"); + item.setFbaWarehouseCode("ONT8"); + when(appointmentMapper.selectOne(any())).thenReturn(existing); + when(itemMapper.selectList(any())).thenReturn(List.of(item)); + + var result = service.batchCreate("100", "7", request(List.of(11L), "APT-1")); + + assertEquals(99L, result.id()); + verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class)); + } + + @Test + void sameAppointmentNumberWithDifferentSelectionConflicts() { + when(appointmentMapper.selectOne(any())).thenReturn(appointment(99L, "APT-1", "ONT8")); + InboundFbaAppointmentItem item = new InboundFbaAppointmentItem(); + item.setLineId(11L); + when(itemMapper.selectList(any())).thenReturn(List.of(item)); + + assertThrows(ResponseStatusException.class, + () -> service.batchCreate("100", "7", request(List.of(12L), "APT-1"))); + } + + private BatchFbaAppointmentRequest request(List lineIds, String number) { + return new BatchFbaAppointmentRequest("100", lineIds, number, appointmentAt, "SUCCESS", "dock"); + } + + private FbaAppointmentLineSnapshot line(Long id, String inboundCode, String fba) { + return new FbaAppointmentLineSnapshot(id, 1000L, id + 100L, inboundCode, fba); + } + + private InboundFbaAppointment appointment(Long id, String number, String fba) { + InboundFbaAppointment value = new InboundFbaAppointment(); + value.setId(id); + value.setCustomerId("100"); + value.setAppointmentNo(number); + value.setAppointmentAt(appointmentAt); + value.setAppointmentStatus("SUCCESS"); + value.setAppointmentRemark("dock"); + value.setFbaWarehouseCode(fba); + value.setSelectedCount(1); + return value; + } +} 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 new file mode 100644 index 000000000..179612fe1 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -0,0 +1,143 @@ +package com.baiyue.lastleg.inboundplan.service; + +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanOrderMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundPlanServiceTest { + @Mock InboundPlanMapper planMapper; + @Mock InboundPlanLineMapper lineMapper; + @Mock InboundPlanPalletMapper palletMapper; + @Mock InboundPlanPalletAllocationMapper allocationMapper; + @Mock InboundPlanOrderMapper orderMapper; + + private InboundPlanService service; + + @BeforeEach + void setUp() { + service = new InboundPlanService(); + ReflectionTestUtils.setField(service, "planMapper", planMapper); + ReflectionTestUtils.setField(service, "lineMapper", lineMapper); + ReflectionTestUtils.setField(service, "palletMapper", palletMapper); + ReflectionTestUtils.setField(service, "allocationMapper", allocationMapper); + ReflectionTestUtils.setField(service, "orderMapper", orderMapper); + org.mockito.Mockito.lenient().when(lineMapper.selectList(any())).thenReturn(List.of()); + org.mockito.Mockito.lenient().when(palletMapper.selectList(any())).thenReturn(List.of()); + org.mockito.Mockito.lenient().when(allocationMapper.selectList(any())).thenReturn(List.of()); + } + + @Test + void convertsWholeContainerInOneTransaction() throws Exception { + assertNotNull(InboundPlanService.class.getMethod("convert", String.class, Long.class) + .getAnnotation(Transactional.class)); + when(orderMapper.findContainerNo("100", 2L)).thenReturn("CONT-1"); + when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of( + order(1L, "RK-1", "1.5000", "WH-A"), + order(2L, "RK-2", "1.0000", "WH-A"))); + when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any())) + .thenReturn(1); + + var result = service.convert("100", 2L); + + ArgumentCaptor plan = ArgumentCaptor.forClass(InboundPlan.class); + verify(planMapper).insert(plan.capture()); + assertEquals(2, plan.getValue().getLineCount()); + assertEquals("PENDING_ASSIGNMENT", plan.getValue().getStatus()); + assertEquals(2, plan.getValue().getPalletCount()); + assertEquals(new BigDecimal("2.5000"), plan.getValue().getTotalVolumeCbm()); + verify(palletMapper, times(2)).insert(any(InboundPlanPallet.class)); + ArgumentCaptor lines = ArgumentCaptor.forClass(InboundPlanLine.class); + verify(lineMapper, times(2)).insert(lines.capture()); + assertEquals(25, lines.getAllValues().stream().mapToInt(InboundPlanLine::getRequiredPackageCount).sum()); + ArgumentCaptor allocations = + ArgumentCaptor.forClass(InboundPlanPalletAllocation.class); + verify(allocationMapper, times(3)).insert(allocations.capture()); + assertEquals(25, allocations.getAllValues().stream() + .mapToInt(InboundPlanPalletAllocation::getPlannedPackageCount).sum()); + assertEquals(0, allocations.getAllValues().stream() + .mapToInt(InboundPlanPalletAllocation::getPickedPackageCount).sum()); + assertEquals(3, allocations.getAllValues().stream() + .filter(item -> "PENDING_COUNT".equals(item.getStatus())).count()); + verify(orderMapper, times(2)).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()); + verify(orderMapper, times(2)).insertHistory(any(), any(), any(), any()); + verify(orderMapper).lockContainerOrders("100", "CONT-1"); + assertEquals("CONT-1", result.containerNo()); + } + + @Test + void repeatedConversionReturnsExistingPlanWithoutWriting() { + InboundPlan existing = new InboundPlan(); + existing.setId(9L); + existing.setPlanNo("LIP9"); + existing.setCustomerId("100"); + existing.setContainerNo("CONT-1"); + existing.setStatus("PLANNED"); + existing.setLineCount(1); + existing.setPalletCount(1); + existing.setTotalVolumeCbm(BigDecimal.ONE); + when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1"); + when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(order(1L, "RK-1", "1", "WH-A"))); + when(planMapper.findByContainer("100", "CONT-1")).thenReturn(existing); + + assertEquals("LIP9", service.convert("100", 1L).planNo()); + verify(planMapper, never()).insert(any(InboundPlan.class)); + verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()); + } + + @Test + void rejectsMissingDestinationWarehouse() { + when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1"); + SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1", + "PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, null, null, null, 1, 0); + when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(invalid)); + + assertThrows(ResponseStatusException.class, () -> service.convert("100", 1L)); + verify(planMapper, never()).insert(any(InboundPlan.class)); + } + + @Test + void rejectsTransferWarehouseThatDiffersFromCargoSnapshots() { + when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1"); + SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1", + "DESTINATION_ASSIGNED", null, BigDecimal.ONE, 1, "WH-B", "WH-B", "Warehouse B", 1, 1); + when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(invalid)); + + assertThrows(ResponseStatusException.class, () -> service.convert("100", 1L)); + verify(planMapper, never()).insert(any(InboundPlan.class)); + } + + private SourceOrderSnapshot order(Long id, String inboundCode, String volume, String warehouse) { + return new SourceOrderSnapshot(id, "100", "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null, + new BigDecimal(volume), new BigDecimal(volume).multiply(BigDecimal.TEN).intValue(), + warehouse, warehouse, warehouse, 1, 0); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/PalletAllocatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/PalletAllocatorTest.java new file mode 100644 index 000000000..663404360 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/PalletAllocatorTest.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.inboundplan.service; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PalletAllocatorTest { + @Test + void roundsEachDestinationGroupUpToTwoCbmPallets() { + assertEquals(2, allocate("4.0000").pallets().size()); + assertEquals(3, allocate("4.0100").pallets().size()); + assertEquals(1, allocate("0.1000").pallets().size()); + assertEquals(0, allocate("0.0000").pallets().size()); + } + + @Test + void neverMixesDestinationWarehousesAndCanSplitOneLine() { + PalletAllocator.Result result = PalletAllocator.allocate(List.of( + new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal("2.5000"), 5), + new PalletAllocator.LineInput(2L, "WH-B", new BigDecimal("0.1000"), 1))); + + assertEquals(3, result.pallets().size()); + assertEquals(2, result.allocations().stream().filter(item -> item.orderId() == 1L).count()); + assertTrue(result.allocations().stream().allMatch(item -> + item.pallet().warehouseKey().equals(item.orderId() == 1L ? "WH-A" : "WH-B"))); + } + + @Test + void distributesPackagesProportionallyAndAssignsRemainderToLastPallet() { + PalletAllocator.Result result = PalletAllocator.allocate(List.of( + new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal("4.0100"), 10))); + + assertEquals(List.of(4, 4, 2), result.allocations().stream() + .map(PalletAllocator.AllocationDraft::plannedPackageCount).toList()); + assertEquals(10, result.allocations().stream() + .mapToInt(PalletAllocator.AllocationDraft::plannedPackageCount).sum()); + } + + @Test + void doesNotLosePackagesWhenEarlyProportionalSharesRoundDown() { + PalletAllocator.Result result = PalletAllocator.allocate(List.of( + new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal("4.0100"), 1))); + + assertEquals(List.of(0, 0, 1), result.allocations().stream() + .map(PalletAllocator.AllocationDraft::plannedPackageCount).toList()); + } + + private PalletAllocator.Result allocate(String volume) { + return PalletAllocator.allocate(List.of( + new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal(volume), + new BigDecimal(volume).signum() == 0 ? 0 : 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 new file mode 100644 index 000000000..a5f96dab4 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java @@ -0,0 +1,121 @@ +package com.baiyue.lastleg.inventory; + +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryOperation; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InventorySchemaContractTest { + @Test + void baselineDefinesInventoryDimensionsStatesAndIdempotency() throws IOException { + String schema; + try (var stream = getClass().getClassLoader().getResourceAsStream("db/lastleg.sql")) { + assertNotNull(stream, "lastleg schema must be available"); + schema = new String(stream.readAllBytes(), StandardCharsets.UTF_8).toLowerCase(); + } + + 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("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")); + 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)")); + assertTrue(schema.contains("warehouse_location_code varchar(128) not null")); + assertFalse(tableDefinition(schema, "ll_inventory_position").contains("sku_code")); + assertFalse(tableDefinition(schema, "ll_inventory_position").contains("product_quantity")); + assertTrue(schema.contains("create table if not exists ll_inventory_ledger")); + assertTrue(schema.contains("business_order_id bigint null")); + assertTrue(schema.contains("inbound_code varchar(128) null")); + assertTrue(schema.contains("before_packages bigint not null")); + assertTrue(schema.contains("delta_packages bigint not null")); + assertTrue(schema.contains("after_packages bigint not null")); + assertTrue(tableDefinition(schema, "ll_inventory_ledger").contains("sku_code")); + assertTrue(tableDefinition(schema, "ll_inventory_ledger").contains("carton_mark")); + assertFalse(tableDefinition(schema, "ll_inventory_ledger").contains("before_quantity")); + 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)")); + assertFalse(tableDefinition(schema, "ll_inventory_ledger").contains("updated_at")); + } + + @Test + void inventoryEntitiesUseSnowflakeIds() throws NoSuchFieldException { + for (Class type : new Class[] { + InventoryBalance.class, InventoryPosition.class, InventoryLedger.class, InventoryOperation.class + }) { + TableId tableId = type.getDeclaredField("id").getAnnotation(TableId.class); + assertNotNull(tableId, type.getSimpleName()); + assertEquals(IdType.ASSIGN_ID, tableId.type(), type.getSimpleName()); + } + } + + @Test + void incrementalMigrationIsAdditiveAndRerunnable() throws IOException { + String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8) + .toLowerCase(); + assertTrue(migration.contains("2026-08-17 / 008")); + assertTrue(migration.contains("create table if not exists ll_inventory_balance")); + assertTrue(migration.contains("create table if not exists ll_inventory_position")); + assertTrue(migration.contains("create table if not exists ll_inventory_ledger")); + assertTrue(migration.contains("create table if not exists ll_inventory_operation")); + assertTrue(migration.contains("2026-08-17 / 015")); + assertTrue(migration.contains("add column inbound_order_id bigint null")); + assertTrue(migration.contains("quantity columns remain untouched")); + assertTrue(migration.contains("2026-08-17 / 018")); + assertTrue(migration.contains("alter table ll_order add column picked_package_count")); + assertTrue(migration.contains("alter table ll_order add column shipped_package_count")); + assertTrue(migration.contains("alter table ll_inventory_balance add column inbound_order_no")); + assertTrue(migration.contains("update ll_inventory_balance")); + assertTrue(migration.contains("where inbound_order_id is null or inbound_code is null or inbound_code = ''")); + assertTrue(migration.contains("alter table ll_inventory_ledger add column inbound_order_id")); + assertTrue(migration.contains("update ll_inventory_ledger")); + assertTrue(migration.contains("alter table ll_dispatch_order add column partial_completion_reason")); + assertFalse(migration.contains("drop column sku_code")); + assertFalse(migration.contains("drop column product_quantity")); + } + + @Test + void entitiesExposeInboundOrderPackageDimensionAndLegacyQuantityPlaceholders() throws NoSuchFieldException { + assertNotNull(InventoryBalance.class.getDeclaredField("inboundOrderId")); + assertNotNull(InventoryBalance.class.getDeclaredField("inboundCode")); + assertNotNull(InventoryLedger.class.getDeclaredField("inboundOrderId")); + assertThrowsNoField(InventoryBalance.class, "skuCode"); + assertThrowsNoField(InventoryBalance.class, "availableQuantity"); + assertThrowsNoField(InventoryPosition.class, "skuCode"); + assertThrowsNoField(InventoryPosition.class, "productQuantity"); + assertNotNull(InventoryLedger.class.getDeclaredField("skuCode")); + assertNotNull(InventoryLedger.class.getDeclaredField("cartonMark")); + assertNotNull(InventoryLedger.class.getDeclaredField("beforeQuantity")); + assertNotNull(InventoryLedger.class.getDeclaredField("deltaQuantity")); + assertNotNull(InventoryLedger.class.getDeclaredField("afterQuantity")); + } + + private static void assertThrowsNoField(Class type, String fieldName) { + assertTrue(java.util.Arrays.stream(type.getDeclaredFields()).noneMatch(field -> field.getName().equals(fieldName)), + () -> type.getSimpleName() + " must not expose " + fieldName); + } + + private static String tableDefinition(String schema, String tableName) { + int start = schema.indexOf("create table if not exists " + tableName); + int end = schema.indexOf("engine=innodb", start); + return schema.substring(start, end); + } +} 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 new file mode 100644 index 000000000..b85001fd9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -0,0 +1,165 @@ +package com.baiyue.lastleg.inventory.command; + +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryOperation; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryOperationMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InventoryCommandServiceTest { + @Mock InventoryBalanceMapper balances; + @Mock InventoryPositionMapper positions; + @Mock InventoryLedgerMapper ledgers; + @Mock InventoryOperationMapper operations; + @Mock InventoryCommandMapper commands; + @Mock WarehouseResourceGateway warehouseGateway; + + private InventoryCommandService service; + + @BeforeEach + void setUp() { + service = new InventoryCommandService(); + ReflectionTestUtils.setField(service, "balances", balances); + ReflectionTestUtils.setField(service, "positions", positions); + ReflectionTestUtils.setField(service, "ledgers", ledgers); + ReflectionTestUtils.setField(service, "operations", operations); + ReflectionTestUtils.setField(service, "commands", commands); + ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + lenient().when(operations.findIdempotency(any(), any())).thenReturn(null); + } + + @Test + 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); + var result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-1", 12, 24)); + + assertEquals(12, result.availablePackages()); + assertFalse(result.replayed()); + ArgumentCaptor balance = ArgumentCaptor.forClass(InventoryBalance.class); + verify(balances).updateById(balance.capture()); + assertEquals(12L, balance.getValue().getAvailablePackages()); + verify(positions).insert(any(InventoryPosition.class)); + verify(ledgers).insert(any(InventoryLedger.class)); + verify(operations).updateById(any(InventoryOperation.class)); + } + + @Test + void stockInAllowsPalletPositionWithZeroOptionalPackages() { + when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null); + + var result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-zero", 0, 0)); + + assertEquals(0, result.availablePackages()); + verify(positions).insert(any(InventoryPosition.class)); + verify(ledgers).insert(any(InventoryLedger.class)); + } + + @Test + void holdRejectsWhenAvailablePackagesAreInsufficient() { + InventoryBalance balance = balance(200L, 5); + InventoryPosition position = position(201L, 200L, 5); + when(commands.lockPosition("100", 201L)).thenReturn(position); + when(commands.lockBalance("100", 200L)).thenReturn(balance); + + assertThrows(ResponseStatusException.class, + () -> service.hold("100", "7", new InventoryCommandRequests.Move( + "100", 201L, 6, "ORD-1", null, null, "hold-1", null))); + verify(positions, never()).updateById(any(InventoryPosition.class)); + verify(balances, never()).updateById(any(InventoryBalance.class)); + } + + @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); + service.stockIn("100", "7", request); + ArgumentCaptor operation = ArgumentCaptor.forClass(InventoryOperation.class); + verify(operations).insert(operation.capture()); + when(operations.findIdempotency("100", "stock-in-1")).thenReturn(operation.getValue()); + + InventoryCommandResponse replay = service.stockIn("100", "7", request); + + assertTrue(replay.replayed()); + verify(balances, times(1)).updateById(any(InventoryBalance.class)); + verify(ledgers, times(1)).insert(any(InventoryLedger.class)); + } + + @Test + void shippingLastPackagesScrapsPalletWithoutReleasingItsVirtualLocation() { + InventoryBalance balance = balance(200L, 0); + balance.setPickedPackages(5L); + InventoryPosition position = position(201L, 200L, 0); + position.setPickedPackages(5L); + when(commands.lockPosition("100", 201L)).thenReturn(position); + when(commands.lockBalance("100", 200L)).thenReturn(balance); + when(commands.palletPackages("100", 300L)).thenReturn(0L); + + var result = service.ship("100", "7", new InventoryCommandRequests.Move( + "100", 201L, 5, "OUT-1", "OUT-1", null, "ship-1", null)); + + assertTrue(result.palletEmpty()); + assertEquals("EMPTY", position.getStatus()); + verify(warehouseGateway).scrapPallet("100", 300L); + verify(ledgers).insert(any(InventoryLedger.class)); + } + + 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, + packages, "ORD-1", "EVT-1", key, null); + } + + private InventoryBalance balance(Long id, long available) { + InventoryBalance value = new InventoryBalance(); + value.setId(id); value.setCustomerId("100"); value.setOverseasWarehouseId("WH-1"); + value.setDestinationWarehouseCode("ONT8"); value.setInboundOrderId(101L); + value.setInboundOrderNo("IN-1"); value.setInboundCode("IN-CODE-1"); + value.setAvailablePackages(available); + value.setHoldPackages(0L); value.setAllocatedPackages(0L); value.setPickedPackages(0L); + value.setVersion(0L); + return value; + } + + private InventoryPosition position(Long id, Long balanceId, long available) { + InventoryPosition value = new InventoryPosition(); + value.setId(id); value.setCustomerId("100"); value.setBalanceId(balanceId); + value.setPalletId(300L); value.setPalletNo("PLT-1"); value.setBatchNo("DEFAULT"); + value.setAvailablePackages(available); value.setHoldPackages(0L); + value.setAllocatedPackages(0L); value.setPickedPackages(0L); value.setReceivingPackages(0L); + value.setVersion(0L); + return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandControllerTest.java new file mode 100644 index 000000000..3fa85c54a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandControllerTest.java @@ -0,0 +1,72 @@ +package com.baiyue.lastleg.inventory.command.controller; + +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 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.util.Set; + +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.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InventoryCommandControllerTest { + @Mock InventoryCommandService service; + private InventoryCommandController controller; + + @BeforeEach + void setUp() { + controller = new InventoryCommandController(); + ReflectionTestUtils.setField(controller, "service", service); + } + + @Test + void inventoryMutationRequiresFormPermission() { + var request = move(); + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> controller.hold(user(Set.of()), request)); + assertEquals(403, exception.getStatusCode().value()); + verify(service, never()).hold(any(), any(), any()); + } + + @Test + void permittedBackOfficeUserCanRunInventoryFormCommand() { + var request = move(); + when(service.hold("100", "7", request)).thenReturn(response()); + controller.hold(user(Set.of(InventoryCommandController.MANAGE_PERMISSION)), request); + verify(service).hold("100", "7", request); + } + + @Test + void physicalAdjustmentRequiresDedicatedPermission() { + var request = new InventoryCommandRequests.Adjustment("100", 1L, 2, "COUNT", "ORD-1", "adj-1", null); + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> controller.adjust(user(Set.of(InventoryCommandController.MANAGE_PERMISSION)), request)); + assertEquals(403, exception.getStatusCode().value()); + verify(service, never()).adjust(any(), any(), any()); + } + + private InventoryCommandRequests.Move move() { + return new InventoryCommandRequests.Move("100", 1L, 2, "ORD-1", null, null, "hold-1", null); + } + + private AuthenticatedUser user(Set permissions) { + return new AuthenticatedUser(7L, 100L, 100L, 8L, "operator", "MERCHANT", "WEB", permissions); + } + + private InventoryCommandResponse response() { + return new InventoryCommandResponse(1L, "HOLD", 2L, 3L, 0, 2, 0, 0, false, false); + } +} 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 new file mode 100644 index 000000000..451032574 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.inventory.query.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import com.baiyue.lastleg.inventory.query.service.InventoryQueryService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +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); + + @Test + void merchantCannotOverrideCustomerScope() { + controller.balances(user(100L, "MERCHANT"), "200", query); + verify(service).balances(eq("100"), eq(query)); + } + + @Test + void platformMustSelectCustomer() { + assertThrows(ResponseStatusException.class, + () -> controller.summary(user(0L, "PLATFORM"), null, query)); + } + + @Test + void platformCanQuerySelectedCustomer() { + controller.summary(user(0L, "PLATFORM"), "200", query); + verify(service).dashboard(eq("200"), eq(query)); + } + + @Test + void unauthenticatedPositionDetailIsRejected() { + InventoryQueryRequests.PositionQuery positionQuery = + new InventoryQueryRequests.PositionQuery(null, null, null); + assertThrows(ResponseStatusException.class, + () -> controller.positions(null, null, 1L, positionQuery)); + } + + private static AuthenticatedUser user(long customerId, String appCode) { + return new AuthenticatedUser(1L, customerId, customerId, 1L, "tester", appCode, "WEB", Set.of()); + } + + private InventoryQueryController controller() { + InventoryQueryController controller = new InventoryQueryController(); + ReflectionTestUtils.setField(controller, "service", service); + return controller; + } +} 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 new file mode 100644 index 000000000..1bbfa8ad6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java @@ -0,0 +1,131 @@ +package com.baiyue.lastleg.inventory.query.service; + +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.query.dto.InventoryDashboardResponse; +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import com.baiyue.lastleg.inventory.query.mapper.InventoryQueryMapper; +import com.baiyue.lastleg.inventory.query.model.InventoryAggregateProjection; +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.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class InventoryQueryServiceTest { + private final InventoryQueryMapper mapper = mock(InventoryQueryMapper.class); + private final InventoryQueryService service = new InventoryQueryService(); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "mapper", mapper); + } + + @Test + void dashboardUsesSameTenantAndFiltersForTotalsAndPalletCount() { + InventoryQueryRequests.BalanceQuery query = balanceQuery("AVAILABLE"); + InventoryAggregateProjection aggregate = new InventoryAggregateProjection(); + aggregate.setDimensionCount(2L); + aggregate.setAvailablePackages(12L); + when(mapper.aggregate("100", query)).thenReturn(aggregate); + when(mapper.countPallets("100", query)).thenReturn(3L); + + InventoryDashboardResponse result = service.dashboard("100", query); + + assertEquals(2, result.inboundOrderCount()); + assertEquals(3, result.palletCount()); + assertEquals(12, result.totalPackages()); + } + + @Test + void balancePageIsBoundToRequestedTenant() { + InventoryQueryRequests.BalanceQuery query = balanceQuery(null); + InventoryBalance balance = new InventoryBalance(); + balance.setId(1L); + balance.setCustomerId("100"); + when(mapper.countBalances("100", query)).thenReturn(1L); + when(mapper.balances("100", query, 0, 20)).thenReturn(List.of(balance)); + + service.balances("100", query); + + verify(mapper).countBalances("100", query); + verify(mapper).balances("100", query, 0, 20); + } + + @Test + void unknownStatusIsRejectedBeforeDatabaseQuery() { + InventoryQueryRequests.BalanceQuery query = balanceQuery("DAMAGED"); + + assertThrows(ResponseStatusException.class, () -> service.balances("100", query)); + verify(mapper, never()).countBalances(any(), any()); + } + + @Test + void positionDetailDoesNotLeakAnotherTenantBalance() { + InventoryQueryRequests.PositionQuery query = new InventoryQueryRequests.PositionQuery(null, null, null); + assertThrows(ResponseStatusException.class, () -> service.positions("100", 99L, query)); + verify(mapper, never()).positions(eq("100"), eq(99L), any()); + } + + @Test + void positionDetailCarriesInboundOrderAndInboundCodeFromOwnedBalance() { + InventoryQueryRequests.PositionQuery query = new InventoryQueryRequests.PositionQuery(null, null, null); + InventoryBalance balance = new InventoryBalance(); + balance.setId(99L); + balance.setInboundOrderId(88L); + balance.setInboundOrderNo("IBP-20260817-001"); + balance.setInboundCode("RK-001"); + InventoryPosition position = new InventoryPosition(); + position.setBalanceId(99L); + position.setAvailablePackages(7L); + when(mapper.ownedBalance("100", 99L)).thenReturn(balance); + when(mapper.positions("100", 99L, query)).thenReturn(List.of(position)); + + var result = service.positions("100", 99L, query).get(0); + + assertEquals(88L, result.inboundOrderId()); + assertEquals("IBP-20260817-001", result.inboundOrderNo()); + assertEquals("RK-001", result.inboundCode()); + assertEquals(7L, result.totalPackages()); + } + + @Test + void reversedLedgerDateRangeIsRejected() { + InventoryQueryRequests.LedgerQuery query = new InventoryQueryRequests.LedgerQuery( + 1, 20, null, null, null, null, null, null, null, null, null, + java.time.LocalDateTime.of(2026, 8, 18, 0, 0), + java.time.LocalDateTime.of(2026, 8, 17, 0, 0)); + + assertThrows(ResponseStatusException.class, () -> service.ledgers("100", query)); + verify(mapper, never()).countLedgers(any(), any()); + } + + @Test + void balanceExportUsesInboundOrderPackageColumnsOnly() { + InventoryQueryRequests.BalanceQuery query = balanceQuery(null); + when(mapper.balances("100", query, 0, 10_000)).thenReturn(List.of()); + + String csv = new String(service.exportBalances("100", query), StandardCharsets.UTF_8); + + assertTrue(csv.contains("inboundOrderNo,inboundCode,totalPackages")); + assertFalse(csv.contains("skuCode")); + 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); + } +} 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 new file mode 100644 index 000000000..0e2a97a8a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.procurement; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OverseasProcurementInventoryBoundaryTest { + @Test + void procurementRemainsABusinessDocumentAndDoesNotMoveAvailableInventory() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java"), + StandardCharsets.UTF_8); + + assertTrue(source.contains("PENDING_REVIEW")); + assertFalse(source.contains("InventoryCommandService")); + assertFalse(source.contains("stockIn(")); + assertFalse(source.contains("setAvailablePackages")); + assertFalse(source.contains("availablePackages")); + } +} 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 new file mode 100644 index 000000000..d9d7cdba0 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.procurement; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OverseasProcurementSchemaContractTest { + @Test + void baselineContainsOrderOutboundAndReviewHistoryTables() 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)")); + } +} 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 new file mode 100644 index 000000000..61559b9ee --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java @@ -0,0 +1,68 @@ +package com.baiyue.lastleg.procurement.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests; +import com.baiyue.lastleg.procurement.service.OverseasProcurementService; +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.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 OverseasProcurementControllerTest { + @Mock OverseasProcurementService service; + private MerchantOverseasProcurementController merchant; + private PlatformOverseasProcurementController platform; + + @BeforeEach + void setUp() { + merchant = new MerchantOverseasProcurementController(); + platform = new PlatformOverseasProcurementController(); + ReflectionTestUtils.setField(merchant, "service", service); + ReflectionTestUtils.setField(platform, "service", service); + } + + @Test + void merchantAlwaysUsesTenantFromTrustedIdentity() { + var query = new OverseasProcurementRequests.Query(1, 20, null, null, null, null, null, null, null, null); + merchant.page(user(100L, "MERCHANT", Set.of()), query); + verify(service).page("100", query); + } + + @Test + void reviewRequiresExplicitPermission() { + var request = new OverseasProcurementRequests.Review("APPROVE", null); + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> platform.review(user(0L, "PLATFORM", Set.of()), "100", "HC1", request)); + assertEquals(403, error.getStatusCode().value()); + } + + @Test + void merchantEndpointRejectsPlatformIdentity() { + var query = new OverseasProcurementRequests.Query(1, 20, null, null, null, null, null, null, null, null); + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> merchant.page(user(0L, "PLATFORM", Set.of()), query)); + assertEquals(403, error.getStatusCode().value()); + } + + @Test + void platformReviewUsesExplicitTargetTenant() { + var request = new OverseasProcurementRequests.Review("APPROVE", "ok"); + platform.review(user(0L, "PLATFORM", Set.of(OverseasProcurementService.REVIEW_PERMISSION)), + "100", "HC1", request); + verify(service).review("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 new file mode 100644 index 000000000..308801744 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java @@ -0,0 +1,103 @@ +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.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.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.when; + +@ExtendWith(MockitoExtension.class) +class OverseasProcurementServiceTest { + @Mock OverseasProcurementOrderMapper orders; + @Mock OverseasProcurementOutboundMapper outbounds; + @Mock OverseasProcurementStatusHistoryMapper histories; + 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, "objectMapper", new ObjectMapper()); + } + + @Test + void customerOrderAlwaysStartsPendingReviewWithZeroInventory() { + AtomicReference saved = new AtomicReference<>(); + when(orders.insert(any(OverseasProcurementOrder.class))).thenAnswer(invocation -> { + saved.set(invocation.getArgument(0)); + 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( + "Acme", "Office", "Labels", "100x150", "https://example.test/product", 500L, + new BigDecimal("26.50"), "USD", "Ningbo", "Buyer", "13800000000", null, "key-1")); + + assertEquals(OverseasProcurementService.PENDING_REVIEW, detail.summary().status()); + assertEquals(0, detail.summary().inboundQuantity()); + assertEquals(0, detail.summary().inventoryQuantity()); + } + + @Test + void pendingOrderCannotReceiveInventory() { + OverseasProcurementOrder order = order(OverseasProcurementService.PENDING_REVIEW, 0, 0); + when(orders.lockNo("100", "HC1")).thenReturn(order); + var request = new OverseasProcurementRequests.Update(0L, null, null, null, null, + 100L, null, null, null, null, null, null, null, null); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.update("100", "HC1", request)); + assertEquals(409, error.getStatusCode().value()); + } + + @Test + void outboundCannotExceedDerivedInventory() { + OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 80); + 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); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.addOutbound("100", "HC1", "7", request)); + assertEquals(409, error.getStatusCode().value()); + } + + private OverseasProcurementOrder order(String status, long inbound, long outbound) { + OverseasProcurementOrder order = new OverseasProcurementOrder(); + order.setId(1L); + order.setCustomerId("100"); + order.setOrderNo("HC1"); + order.setStatus(status); + order.setInboundQuantity(inbound); + order.setOutboundQuantity(outbound); + order.setPurchaseQuantity(500L); + order.setVersion(0L); + order.setCreatedAt(LocalDateTime.now()); + order.setUpdatedAt(order.getCreatedAt()); + return order; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java new file mode 100644 index 000000000..e603bb626 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.relabel; + +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.RecordComponent; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RelabelContractTest { + @Test + void createHeaderDoesNotExposeWaybillLabelFileAndItemCarriesRequestedFileFields() { + assertFalse(Arrays.stream(RelabelRequests.Create.class.getRecordComponents()) + .map(RecordComponent::getName) + .anyMatch(name -> name.toLowerCase().contains("waybill") || name.toLowerCase().contains("labelFile"))); + assertTrue(Arrays.stream(RelabelRequests.Item.class.getRecordComponents()) + .map(RecordComponent::getName).toList().containsAll(Arrays.asList("skuCode", "productImageFileId", + "productNameZh", "totalPackageCount", "notifiedPackageCount", "shipmentNo", "trackingCode", + "newSkuCode", "labelRequirement", "relabelFileId"))); + } + + @Test + void schemasDefineRelabelApplicationItemsAndHistoryContracts() throws Exception { + String baseline = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8).toLowerCase(); + String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8).toLowerCase(); + for (String schema : new String[] { baseline, migration }) { + assertTrue(schema.contains("ll_relabel_application")); + assertTrue(schema.contains("ll_relabel_item")); + assertTrue(schema.contains("ll_relabel_status_history")); + assertTrue(schema.contains("customer_id")); + assertTrue(schema.contains("idempotency_key")); + assertTrue(schema.contains("request_hash")); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java new file mode 100644 index 000000000..97a8dad24 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -0,0 +1,259 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.dto.RelabelResponses; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RelabelApplicationServiceTest { + @Mock RelabelApplicationMapper applications; + @Mock RelabelItemMapper items; + @Mock RelabelStatusHistoryMapper histories; + @Mock LastlegOrderMapper orders; + @Mock LastlegOrderStatusHistoryMapper orderHistories; + @Mock LastlegOrderNoGenerator orderNoGenerator; + + private RelabelApplicationService service; + + @BeforeEach + void setUp() { + service = new RelabelApplicationService(); + ReflectionTestUtils.setField(service, "applications", applications); + ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "histories", histories); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "orderHistories", orderHistories); + ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator); + ReflectionTestUtils.setField(service, "objectMapper", mapper()); + lenient().when(orderNoGenerator.next()).thenReturn("LMO-RELABEL-1"); + } + + @Test + void createBuildsPendingRelabelTailOrderItemsAndBothHistories() { + RelabelRequests.Create request = request("key-1", 10, 6); + RelabelApplication persisted = application(11L, "FBR-1", "TENANT-A", "key-1", null, + RelabelApplicationService.PENDING_PROCESSING); + persisted.setLastlegOrderId(21L); + persisted.setItemCount(1); persisted.setTotalPackageCount(10); persisted.setNotifiedPackageCount(6); + when(applications.findNo(eq("TENANT-A"), any())).thenReturn(persisted); + when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 10, 6))); + when(histories.selectList(any())).thenReturn(List.of()); + when(orders.selectById(21L)).thenReturn(order(21L, "TENANT-A", "LMO-RELABEL-1", + RelabelApplicationService.PENDING_PROCESSING)); + + RelabelResponses.Detail result = service.create("TENANT-A", "operator-1", request); + + ArgumentCaptor tailOrder = ArgumentCaptor.forClass(LastlegOrder.class); + ArgumentCaptor application = ArgumentCaptor.forClass(RelabelApplication.class); + ArgumentCaptor relabelItem = ArgumentCaptor.forClass(RelabelItem.class); + ArgumentCaptor relabelHistory = ArgumentCaptor.forClass(RelabelStatusHistory.class); + ArgumentCaptor tailHistory = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class); + verify(orders).insert(tailOrder.capture()); + verify(applications).insert(application.capture()); + verify(items).insert(relabelItem.capture()); + verify(histories).insert(relabelHistory.capture()); + verify(orderHistories).insert(tailHistory.capture()); + + assertEquals("RELABEL_APPLICATION", tailOrder.getValue().getOrderType()); + assertEquals("PENDING_ASSIGNMENT", tailOrder.getValue().getCurrentStatus()); + assertEquals("RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus()); + assertEquals("CUSTOMER", tailOrder.getValue().getSourceRegion()); + assertEquals("LMO-RELABEL-1", tailOrder.getValue().getOrderNo()); + assertEquals(tailOrder.getValue().getInboundCode(), tailOrder.getValue().getSourceInboundId()); + assertEquals(tailOrder.getValue().getInboundCode(), application.getValue().getRelabelNo()); + assertTrue(tailOrder.getValue().getInboundCode().startsWith("FBR")); + assertEquals(10, tailOrder.getValue().getPackageCount()); + assertEquals(6, application.getValue().getNotifiedPackageCount()); + assertEquals("SKU-A", relabelItem.getValue().getSkuCode()); + assertEquals("PENDING_PROCESSING", relabelHistory.getValue().getToStatus()); + assertEquals("PENDING_ASSIGNMENT", tailHistory.getValue().getToStatus()); + assertEquals("FBR-1", result.application().relabelNo()); + } + + @Test + void createRejectsNotifiedBoxesAboveTotalBeforeAnyWrite() { + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.create("TENANT-A", "operator-1", request("key-1", 5, 6))); + + assertEquals(400, exception.getStatusCode().value()); + verify(orders, never()).insert(any(LastlegOrder.class)); + verify(applications, never()).insert(any(RelabelApplication.class)); + verify(items, never()).insert(any(RelabelItem.class)); + } + + @Test + void sameIdempotencyKeyAndPayloadReturnsExistingApplicationWithoutWrites() { + RelabelRequests.Create request = request("key-1", 10, 6); + RelabelApplication existing = application(11L, "FBR-1", "TENANT-A", "key-1", hash(request), + RelabelApplicationService.PENDING_PROCESSING); + existing.setLastlegOrderId(21L); existing.setItemCount(1); existing.setTotalPackageCount(10); + existing.setNotifiedPackageCount(6); + when(applications.findKey("TENANT-A", "key-1")).thenReturn(existing); + stubDetail(existing); + + RelabelResponses.Detail result = service.create("TENANT-A", "operator-1", request); + + assertEquals("FBR-1", result.application().relabelNo()); + verify(orders, never()).insert(any(LastlegOrder.class)); + verify(applications, never()).insert(any(RelabelApplication.class)); + verify(items, never()).insert(any(RelabelItem.class)); + } + + @Test + void idempotencyKeyCannotBeReusedWithDifferentRelabelPayload() { + RelabelRequests.Create request = request("key-1", 10, 6); + RelabelApplication existing = application(11L, "FBR-1", "TENANT-A", "key-1", "different-hash", + RelabelApplicationService.PENDING_PROCESSING); + when(applications.findKey("TENANT-A", "key-1")).thenReturn(existing); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.create("TENANT-A", "operator-1", request)); + + assertEquals(409, exception.getStatusCode().value()); + verify(orders, never()).insert(any(LastlegOrder.class)); + } + + @Test + void statusChangeIsTenantScopedAndWritesApplicationAndTailOrderHistory() { + RelabelApplication application = application(11L, "FBR-1", "TENANT-A", "key-1", "hash", + RelabelApplicationService.PENDING_PROCESSING); + application.setLastlegOrderId(21L); + when(applications.lockNo("TENANT-A", "FBR-1")).thenReturn(application); + stubDetail(application); + + RelabelResponses.Detail result = service.changeStatus("TENANT-A", "FBR-1", "operator-2", + new RelabelRequests.ChangeStatus("processing", "started")); + + assertEquals("PROCESSING", application.getStatus()); + assertEquals("PROCESSING", result.application().status()); + verify(applications).updateById(application); + ArgumentCaptor updatedOrder = ArgumentCaptor.forClass(LastlegOrder.class); + verify(orders).updateById(updatedOrder.capture()); + assertEquals("PENDING_ASSIGNMENT", updatedOrder.getValue().getCurrentStatus()); + assertEquals("RELABEL:PROCESSING", updatedOrder.getValue().getSubOrderStatus()); + verify(histories).insert(any(RelabelStatusHistory.class)); + verify(orderHistories).insert(any(LastlegOrderStatusHistory.class)); + } + + @Test + void completedApplicationCannotReturnToProcessingAndOtherTenantCannotReadIt() { + RelabelApplication completed = application(11L, "FBR-1", "TENANT-A", "key-1", "hash", "COMPLETED"); + completed.setLastlegOrderId(21L); + when(applications.lockNo("TENANT-A", "FBR-1")).thenReturn(completed); + + ResponseStatusException transition = assertThrows(ResponseStatusException.class, () -> + service.changeStatus("TENANT-A", "FBR-1", "operator-2", new RelabelRequests.ChangeStatus("PROCESSING", null))); + assertEquals(409, transition.getStatusCode().value()); + + when(applications.findNo("TENANT-B", "FBR-1")).thenReturn(null); + ResponseStatusException boundary = assertThrows(ResponseStatusException.class, () -> + service.detail("TENANT-B", "FBR-1")); + assertEquals(404, boundary.getStatusCode().value()); + verify(applications).findNo("TENANT-B", "FBR-1"); + } + + @Test + void completedReturnRelabelOrderEntersPendingAssignment() { + RelabelApplication application = application(11L, "FBR-1", "TENANT-A", "key-1", "hash", "PROCESSING"); + application.setLastlegOrderId(21L); + LastlegOrder order = order(21L, "TENANT-A", "LMO-RELABEL-1", "PROCESSING"); + order.setOrderType("RETURN_RELABEL"); + when(applications.lockNo("TENANT-A", "FBR-1")).thenReturn(application); + stubDetail(application); + when(orders.selectById(21L)).thenReturn(order); + + service.changeStatus("TENANT-A", "FBR-1", "operator-2", + new RelabelRequests.ChangeStatus("COMPLETED", "finished")); + + assertEquals("COMPLETED", application.getStatus()); + assertEquals("PENDING_ASSIGNMENT", order.getCurrentStatus()); + assertEquals("COMPLETED", order.getRelabelStatus()); + } + + private void stubDetail(RelabelApplication application) { + when(applications.findNo(application.getCustomerId(), application.getRelabelNo())).thenReturn(application); + when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 10, 6))); + when(histories.selectList(any())).thenReturn(List.of()); + when(orders.selectById(application.getLastlegOrderId())).thenReturn(order(application.getLastlegOrderId(), + application.getCustomerId(), "LMO-RELABEL-1", application.getStatus())); + } + + private static RelabelRequests.Create request(String key, int total, int notified) { + return new RelabelRequests.Create("CUSTOMER-1", "WH-1", LocalDateTime.of(2026, 8, 20, 10, 0), + "Li", "13800000000", "remark", key, List.of(new RelabelRequests.Item("SKU-A", "img-1", + "image.png", "https://files/image.png", "Chinese name", total, notified, "SHP-1", "TRACK-1", + "SKU-NEW", "Label on carton", "file-1", "relabel.pdf", "https://files/relabel.pdf"))); + } + + private static RelabelApplication application(long id, String no, String customerId, String key, String requestHash, + String status) { + RelabelApplication value = new RelabelApplication(); + value.setId(id); value.setCustomerId(customerId); value.setRelabelNo(no); value.setIdempotencyKey(key); + value.setRequestHash(requestHash); value.setStatus(status); value.setCreatedAt(LocalDateTime.now()); + value.setContactName("Li"); value.setContactPhone("13800000000"); value.setCustomerOrderNo("CUSTOMER-1"); + value.setOverseasWarehouseId("WH-1"); value.setLabelDeadline(LocalDateTime.now().plusDays(1)); + value.setItemCount(1); value.setTotalPackageCount(10); value.setNotifiedPackageCount(6); + return value; + } + + private static RelabelItem item(long id, long applicationId, String sku, int total, int notified) { + RelabelItem value = new RelabelItem(); + value.setId(id); value.setApplicationId(applicationId); value.setCustomerId("TENANT-A"); value.setSkuCode(sku); + value.setProductNameZh("Chinese name"); value.setTotalPackageCount(total); value.setNotifiedPackageCount(notified); + value.setShipmentNo("SHP-1"); value.setNewSkuCode("SKU-NEW"); + return value; + } + + private static LastlegOrder order(long id, String customerId, String orderNo, String status) { + LastlegOrder value = new LastlegOrder(); + value.setId(id); value.setCustomerId(customerId); value.setOrderNo(orderNo); value.setCurrentStatus(status); + return value; + } + + private static String hash(RelabelRequests.Create request) { + try { + byte[] digest = java.security.MessageDigest.getInstance("SHA-256") + .digest(mapper().writeValueAsString(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(digest); + } catch (Exception exception) { + throw new AssertionError(exception); + } + } + + private static ObjectMapper mapper() { + return new ObjectMapper().findAndRegisterModules(); + } +} 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 new file mode 100644 index 000000000..bab9745c3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java @@ -0,0 +1,140 @@ +package com.baiyue.lastleg.schema; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SchemaChineseCommentContractTest { + + private static final Pattern CHINESE = Pattern.compile("[\\u4e00-\\u9fff]"); + private static final Pattern TABLE = Pattern.compile( + "(?is)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+(ll_[a-z0-9_]+)" + + "\\s*\\((.*?)\\)\\s*ENGINE\\s*=\\s*InnoDB\\s+COMMENT\\s*=\\s*'([^']*)'\\s*;"); + + @Test + void allLastlegTablesAndColumnsHaveChineseComments() throws Exception { + String baseline = Files.readString( + Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8); + String incremental = Files.readString( + Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8); + + List baselineTables = assertAllChineseComments(baseline, "基线 SQL", 55); + assertAllChineseComments(incremental, "增量 SQL", 49); + assertCommentMigrationCovers(incremental, baselineTables); + } + + @Test + void newlyAddedColumnsCarryChineseCommentsInBaselineAndIncrementalSql() throws Exception { + String baseline = Files.readString( + Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8); + String incremental = Files.readString( + Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8); + + List columns = List.of( + "allocation_idempotency_key", "allocation_request_hash", "outbound_idempotency_key", + "outbound_request_hash", "outbound_at", "pallet_count", "received_pallet_count", + "allocated_pallet_count", "shipped_pallet_count", "receipt_idempotency_key", + "receipt_request_hash" + ); + for (String column : columns) { + assertColumnHasChineseComment(baseline, column, "基线 SQL"); + assertColumnHasChineseComment(incremental, column, "增量 SQL"); + assertIncrementalAddColumnHasChineseComment(incremental, column); + } + } + + private static List assertAllChineseComments(String sql, String source, int expectedCount) { + List tables = new ArrayList<>(); + Matcher matcher = TABLE.matcher(sql); + while (matcher.find()) { + String table = matcher.group(1); + assertTrue(CHINESE.matcher(matcher.group(3)).find(), source + " 表备注必须包含中文: " + table); + List columns = splitTopLevel(matcher.group(2)); + assertFalse(columns.isEmpty(), source + " 表定义没有字段: " + table); + List columnNames = new ArrayList<>(); + for (String definition : columns) { + if (isConstraint(definition)) { + continue; + } + String column = definition.stripLeading().split("\\s+", 2)[0].replace("`", ""); + columnNames.add(column); + assertTrue(Pattern.compile("(?is)\\bCOMMENT\\s+'[^']*[\\u4e00-\\u9fff][^']*'") + .matcher(definition).find(), + source + " 字段备注必须包含中文: " + table + "." + column); + } + tables.add(new TableDefinition(table, columnNames)); + } + assertTrue(tables.size() >= expectedCount, + source + " 解析到的尾程表数量不足,实际=" + tables.size() + ",期望至少=" + expectedCount); + return tables; + } + + private static void assertCommentMigrationCovers(String incremental, 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() + "'"), + "025 备注迁移缺少表: " + table.name()); + for (String column : table.columns()) { + assertTrue(migration.contains("table_name='" + table.name() + + "' AND column_name='" + column + "'"), + "025 备注迁移缺少字段: " + table.name() + "." + column); + } + } + } + + private static boolean isConstraint(String definition) { + String normalized = definition.stripLeading().toUpperCase(); + return normalized.startsWith("PRIMARY ") || normalized.startsWith("UNIQUE ") + || normalized.startsWith("KEY ") || normalized.startsWith("CONSTRAINT ") + || normalized.startsWith("FOREIGN ") || normalized.startsWith("CHECK "); + } + + private static void assertColumnHasChineseComment(String sql, String column, String source) { + Pattern columnPattern = Pattern.compile( + "(?is)\\b" + Pattern.quote(column) + "\\b[^,;]*?COMMENT\\s+''?[^',;]*[\\u4e00-\\u9fff][^',;]*''?"); + assertTrue(columnPattern.matcher(sql).find(), source + " 新增字段缺少中文备注: " + column); + } + + private static void assertIncrementalAddColumnHasChineseComment(String sql, String column) { + Pattern addColumnPattern = Pattern.compile( + "(?is)ADD\\s+COLUMN\\s+" + Pattern.quote(column) + + "\\b[^;]*?COMMENT\\s+''[^';]*[\\u4e00-\\u9fff][^';]*''"); + assertTrue(addColumnPattern.matcher(sql).find(), "增量 SQL 的 ADD COLUMN 缺少中文备注: " + column); + } + + private static List splitTopLevel(String body) { + List definitions = new ArrayList<>(); + int start = 0; + int parentheses = 0; + boolean quoted = false; + for (int i = 0; i < body.length(); i++) { + char current = body.charAt(i); + if (current == '\'' && (i == 0 || body.charAt(i - 1) != '\\')) { + quoted = !quoted; + } else if (!quoted && current == '(') { + parentheses++; + } else if (!quoted && current == ')') { + parentheses--; + } else if (!quoted && parentheses == 0 && current == ',') { + definitions.add(body.substring(start, i).trim()); + start = i + 1; + } + } + definitions.add(body.substring(start).trim()); + return definitions.stream().filter(value -> !value.isEmpty()).toList(); + } + + private record TableDefinition(String name, List columns) { + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java new file mode 100644 index 000000000..49b0a41a3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.security; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import static org.junit.jupiter.api.Assertions.*; +class InternalServiceTokenGuardTest { + @Test void acceptsOnlyConfiguredToken() { + InternalServiceTokenGuard guard=new InternalServiceTokenGuard("secret-token"); + assertDoesNotThrow(()->guard.verify("secret-token")); + assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify("wrong")).getStatusCode().value()); + assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify(null)).getStatusCode().value()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java new file mode 100644 index 000000000..410b21505 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.service; +import org.junit.jupiter.api.Test; +import java.util.Set; +import java.util.concurrent.*; +import java.util.stream.IntStream; +import static org.junit.jupiter.api.Assertions.*; +class LastlegOrderNoGeneratorTest { + @Test void actualContainerNumberIsStableAndShared() { + LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator(); + assertEquals("LMO2087379674555461633", generator.forContainerNo("2087379674555461633")); + assertEquals(generator.forContainerNo("MSCU1"), new LastlegOrderNoGenerator().forContainerNo("MSCU1")); + assertNotEquals(generator.forContainerNo("MSCU1"), generator.forContainerNo("CAB1")); + assertTrue(generator.forContainerNo("MSCU1234567890").length() <= 32); + assertThrows(IllegalArgumentException.class, () -> generator.forContainerNo(" ")); + } + + @Test void remainsUniqueAcrossInstancesAndConcurrentCalls() throws Exception { + LastlegOrderNoGenerator beforeRestart=new LastlegOrderNoGenerator(); + String first=beforeRestart.next(); + LastlegOrderNoGenerator afterRestart=new LastlegOrderNoGenerator(); + assertNotEquals(first,afterRestart.next()); + ExecutorService pool=Executors.newFixedThreadPool(8); + try { + var tasks=IntStream.range(0,1000).mapToObj(i->(Callable)afterRestart::next).toList(); + Set ids=pool.invokeAll(tasks).stream().map(future->{ + try { return future.get(); } catch(Exception exception) { throw new RuntimeException(exception); } + }).collect(java.util.stream.Collectors.toSet()); + assertEquals(1000,ids.size()); + } finally { pool.shutdownNow(); } + } +} 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 new file mode 100644 index 000000000..b2eae4f27 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -0,0 +1,144 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; +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; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class LastlegOrderServiceTest { + @Mock LastlegOrderMapper orders; + @Mock LastlegOrderDetailMapper details; + @Mock LastlegOrderStatusHistoryMapper histories; + @Mock DropshipOrderProfileMapper dropshipProfiles; + @Mock DropshipSkuInventoryMapper dropshipSkuInventory; + @Mock DropshipCartonMapper dropshipCartons; + LastlegOrderService service; + + @BeforeEach + void setUp() { + service = new LastlegOrderService(); + 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() { + 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()); + } + + @Test + void transferUpdatesOrderAndEveryCargoSnapshot() { + LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null); + when(orders.selectList(any())).thenReturn(List.of(order)); + var request = new LastlegOrderRequests.TransferDestination( + List.of(11L), "wh-2", "LAX-02", "Los Angeles 02", null); + + assertEquals(1, service.transferDestination("100", request)); + + assertEquals("wh-2", order.getTransferWarehouseId()); + assertEquals("LAX-02", order.getTransferWarehouseCode()); + assertEquals("DESTINATION_ASSIGNED", order.getCurrentStatus()); + ArgumentCaptor cargo = ArgumentCaptor.forClass(LastlegOrderDetail.class); + verify(details).update(cargo.capture(), any()); + assertEquals("LAX-02", cargo.getValue().getDestinationWarehouseCode()); + ArgumentCaptor history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class); + verify(histories).insert(history.capture()); + assertEquals("PENDING_ASSIGNMENT", history.getValue().getFromStatus()); + assertEquals("DESTINATION_ASSIGNED", history.getValue().getToStatus()); + } + + @Test + void transferRejectsAnOrderAlreadyBoundToInboundPlan() { + LastlegOrder order = order(11L, "100", "DESTINATION_ASSIGNED", "IB-1"); + when(orders.selectList(any())).thenReturn(List.of(order)); + var request = new LastlegOrderRequests.TransferDestination( + List.of(11L), "wh-2", "LAX-02", "Los Angeles 02", null); + + assertThrows(ResponseStatusException.class, () -> service.transferDestination("100", request)); + verify(orders, never()).updateById(any(LastlegOrder.class)); + verify(details, never()).update(any(), any()); + } + + @Test + void batchRejectsMissingOrCrossCustomerIdsBeforeWriting() { + when(orders.selectList(any())).thenReturn(List.of(order(11L, "100", "PENDING_ASSIGNMENT", null))); + var request = new LastlegOrderRequests.TransferDestination( + List.of(11L, 22L), "wh-2", "LAX-02", "Los Angeles 02", null); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.transferDestination("100", request)); + assertTrue(error.getStatusCode().is4xxClientError()); + verify(orders, never()).updateById(any(LastlegOrder.class)); + } + + private static LastlegOrder order(long id, String customerId, String status, String inboundOrderNo) { + LastlegOrder order = new LastlegOrder(); + order.setId(id); + order.setCustomerId(customerId); + order.setCurrentStatus(status); + order.setInboundOrderNo(inboundOrderNo); + 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/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java new file mode 100644 index 000000000..30aa475d0 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.service; +import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) +class LoadingCompletedConsumerTest { + @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock DropshipOrderService dropshipOrders; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; + @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); } + @Test void createsOneOrderPerInboundAndAllSnapshots(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } + @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } + @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } + @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } + @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet)); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } + private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferSchemaContractTest.java new file mode 100644 index 000000000..7dcc994a7 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferSchemaContractTest.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.transfer; + +import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WarehouseTransferSchemaContractTest { + @Test + void baselineAndIncrementalContainTransferAndHistoricalOrderBindingTables() throws Exception { + String baseline = Files.readString(Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8).toLowerCase(); + String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8).toLowerCase(); + for (String table : new String[]{"ll_warehouse_transfer_order", "ll_warehouse_transfer_source_order", + "ll_warehouse_transfer_item", "ll_warehouse_transfer_inventory_allocation", + "ll_warehouse_transfer_receipt"}) { + assertTrue(baseline.contains("create table if not exists " + table)); + assertTrue(migration.contains("create table if not exists " + table)); + } + assertTrue(migration.contains("information_schema.statistics")); + assertTrue(migration.contains("uk_ll_wh_transfer_source")); + assertTrue(migration.contains("uk_ll_wh_transfer_active_position")); + assertTrue(migration.contains("uk_ll_wh_transfer_receipt_source")); + } +} 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 new file mode 100644 index 000000000..58ee4bcef --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -0,0 +1,371 @@ +package com.baiyue.lastleg.transfer.service; + +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder; +import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WarehouseTransferServiceTest { + @Mock WarehouseTransferOrderMapper transfers; + @Mock WarehouseTransferItemMapper transferItems; + @Mock WarehouseTransferSourceOrderMapper sourceOrders; + @Mock WarehouseTransferInventoryAllocationMapper inventoryAllocations; + @Mock WarehouseTransferReceiptMapper transferReceipts; + @Mock InboundSnapshotMapper inboundSnapshots; + @Mock LastlegOrderMapper orders; + @Mock LastlegOrderDetailMapper orderDetails; + @Mock LastlegOrderStatusHistoryMapper orderHistories; + @Mock RelabelApplicationMapper relabelApplications; + @Mock RelabelItemMapper relabelItems; + @Mock RelabelStatusHistoryMapper relabelHistories; + @Mock LastlegOrderNoGenerator orderNoGenerator; + @Mock InventoryCommandService inventory; + @Mock InventoryCommandMapper inventoryCommands; + private WarehouseTransferService service; + + @BeforeEach + void setUp() { + service = new WarehouseTransferService(); + ReflectionTestUtils.setField(service, "transfers", transfers); + ReflectionTestUtils.setField(service, "transferItems", transferItems); + ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders); + ReflectionTestUtils.setField(service, "inventoryAllocations", inventoryAllocations); + ReflectionTestUtils.setField(service, "transferReceipts", transferReceipts); + ReflectionTestUtils.setField(service, "inboundSnapshots", inboundSnapshots); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "orderDetails", orderDetails); + ReflectionTestUtils.setField(service, "orderHistories", orderHistories); + ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications); + ReflectionTestUtils.setField(service, "relabelItems", relabelItems); + ReflectionTestUtils.setField(service, "relabelHistories", relabelHistories); + ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator); + ReflectionTestUtils.setField(service, "inventory", inventory); + ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); + lenient().when(orderNoGenerator.next()).thenReturn("LMO-TRANSFER-1"); + lenient().when(inventoryAllocations.selectList(any())).thenReturn(List.of()); + lenient().when(transferReceipts.selectList(any())).thenReturn(List.of()); + } + + @Test + void relabelTransferLinksHistoricalOrderAndInboundAndCreatesOnePendingReviewTailOrder() { + WarehouseTransferRequests.Create request = request(true); + LastlegOrder historical = historicalOrder(); + when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L); + when(orders.selectList(any())).thenReturn(List.of(historical)); + when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer()); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + var result = service.create("TENANT-A", "USER-1", request); + + ArgumentCaptor tailOrder = ArgumentCaptor.forClass(LastlegOrder.class); + verify(orders).insert(tailOrder.capture()); + assertEquals(WarehouseTransferService.RETURN_RELABEL_ORDER_TYPE, tailOrder.getValue().getOrderType()); + assertEquals("PENDING_ASSIGNMENT", tailOrder.getValue().getCurrentStatus()); + assertEquals("WAREHOUSE_TRANSFER:PENDING_REVIEW;RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus()); + assertEquals("LMO-TRANSFER-1", tailOrder.getValue().getOrderNo()); + assertNull(tailOrder.getValue().getTransferWarehouseId()); + verify(sourceOrders).insert(any(WarehouseTransferSourceOrder.class)); + verify(relabelApplications).insert(any(RelabelApplication.class)); + verify(relabelItems).insert(any(RelabelItem.class)); + verify(relabelHistories).insert(any(RelabelStatusHistory.class)); + verify(orders).updateById(tailOrder.getValue()); + assertNotNull(tailOrder.getValue().getRelabelOrderNo()); + assertEquals("PENDING_PROCESSING", tailOrder.getValue().getRelabelStatus()); + assertEquals(WarehouseTransferService.PENDING_REVIEW, result.orderStatus()); + } + + @Test + void transferWithoutRelabelDoesNotCreateRelabelDocument() { + when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L); + when(orders.selectList(any())).thenReturn(List.of(historicalOrder())); + when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer()); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + var result = service.create("TENANT-A", "USER-1", request(false)); + + verify(relabelApplications, never()).insert(any(RelabelApplication.class)); + verify(relabelItems, never()).insert(any(RelabelItem.class)); + verify(relabelHistories, never()).insert(any(RelabelStatusHistory.class)); + assertNull(result.relabelNo()); + } + + @Test + void approvedTransferWithoutRelabelEntersPendingAssignment() { + WarehouseTransferOrder transfer = persistedTransfer(); + transfer.setStatus(WarehouseTransferService.PENDING_REVIEW); + transfer.setNeedRelabel(false); + LastlegOrder tailOrder = historicalOrder(); + tailOrder.setId(31L); tailOrder.setCurrentStatus(WarehouseTransferService.PENDING_REVIEW); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(orders.selectById(31L)).thenReturn(tailOrder); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + var result = service.review("TENANT-A", "WTR-1", "USER-1", + new WarehouseTransferRequests.Review("APPROVE", "approved")); + + assertEquals(WarehouseTransferService.APPROVED, transfer.getStatus()); + assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, tailOrder.getCurrentStatus()); + assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, result.orderStatus()); + verify(transfers).updateById(transfer); + verify(orders).updateById(tailOrder); + verifyNoInteractions(inventory); + } + + @Test + void approvedTransferAllocatesExactSourceInboundPalletWithoutMovingInventory() { + WarehouseTransferOrder transfer = transfer("APPROVED"); + LastlegOrder tailOrder = tailOrder(); + WarehouseTransferSourceOrder source = sourceOrder(); + InventoryPosition position = position(501L, 601L, 701L, 5L); + InventoryBalance balance = balance(601L, 101L, "IN-1"); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(sourceOrders.selectList(any())).thenReturn(List.of(source)); + when(inventoryCommands.lockPosition("TENANT-A", 501L)).thenReturn(position); + when(inventoryCommands.lockBalance("TENANT-A", 601L)).thenReturn(balance); + when(orders.selectById(31L)).thenReturn(tailOrder); + when(transferItems.selectList(any())).thenReturn(List.of()); + + var result = service.allocate("TENANT-A", "WTR-1", "USER-1", + new WarehouseTransferRequests.Allocate("ALLOC-1", + List.of(new WarehouseTransferRequests.Allocation(501L, 701L, 5)))); + + ArgumentCaptor captured = + ArgumentCaptor.forClass(WarehouseTransferInventoryAllocation.class); + verify(inventoryAllocations).insert(captured.capture()); + assertEquals(101L, captured.getValue().getInboundOrderId()); + assertEquals(701L, captured.getValue().getPalletId()); + assertEquals(5, captured.getValue().getPackageCount()); + assertEquals(WarehouseTransferService.ALLOCATED, transfer.getStatus()); + assertEquals("WAIT_OUTBOUND", result.orderStatus()); + verifyNoInteractions(inventory); + } + + @Test + void outboundMovesExactPackagesAllocatePickShipInOrderAndReceiptStocksInSameCount() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED); + LastlegOrder tailOrder = tailOrder(); + WarehouseTransferInventoryAllocation allocation = allocation(); + 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(orders.selectById(31L)).thenReturn(tailOrder); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + service.outbound("TENANT-A", "WTR-1", "USER-1", + new WarehouseTransferRequests.Outbound("OUT-1", "departed")); + + var ordered = inOrder(inventory); + ArgumentCaptor move = + ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move.class); + ordered.verify(inventory).allocate(eq("TENANT-A"), eq("USER-1"), move.capture()); + ordered.verify(inventory).pick(eq("TENANT-A"), eq("USER-1"), move.capture()); + ordered.verify(inventory).ship(eq("TENANT-A"), eq("USER-1"), move.capture()); + assertTrue(move.getAllValues().stream().allMatch(value -> value.positionId() == 501L && value.packages() == 5)); + assertEquals(WarehouseTransferService.OUTBOUNDED, transfer.getStatus()); + assertEquals("SHIPPED", allocation.getStatus()); + + clearInvocations(inventory); + service.outbound("TENANT-A", "WTR-1", "USER-1", + new WarehouseTransferRequests.Outbound("OUT-1", "departed")); + verifyNoInteractions(inventory); + + when(transferReceipts.findSource("TENANT-A", 21L, 501L)).thenReturn(null); + when(inventory.stockIn(eq("TENANT-A"), eq("USER-1"), any())).thenReturn( + new InventoryCommandResponse(1L, "STOCK_IN", 801L, 901L, 5, 0, 0, 0, false, false)); + service.receive("TENANT-A", "WTR-1", "USER-1", receiptRequest()); + + ArgumentCaptor stockIn = + 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("WTR-1", stockIn.getValue().businessOrderNo()); + assertEquals("WAREHOUSE_TRANSFER_RECEIPT:WTR-1:501", stockIn.getValue().idempotencyKey()); + assertEquals(WarehouseTransferService.RECEIVED, transfer.getStatus()); + assertEquals("RECEIVED", allocation.getStatus()); + + ArgumentCaptor receipt = + ArgumentCaptor.forClass(com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt.class); + verify(transferReceipts).insert(receipt.capture()); + when(transferReceipts.findSource("TENANT-A", 21L, 501L)).thenReturn(receipt.getValue()); + clearInvocations(inventory); + service.receive("TENANT-A", "WTR-1", "USER-1", receiptRequest()); + verifyNoInteractions(inventory); + } + + @Test + void outboundFailureDoesNotAdvanceTransferOrPersistAllocationShipment() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED); + WarehouseTransferInventoryAllocation allocation = allocation(); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(inventoryAllocations.lockTransfer("TENANT-A", 21L)).thenReturn(List.of(allocation)); + doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "insufficient")) + .when(inventory).pick(eq("TENANT-A"), eq("USER-1"), any()); + + assertThrows(ResponseStatusException.class, () -> service.outbound("TENANT-A", "WTR-1", "USER-1", + new WarehouseTransferRequests.Outbound("OUT-1", null))); + + verify(inventory, never()).ship(eq("TENANT-A"), eq("USER-1"), any()); + verify(inventoryAllocations, never()).updateById(any(WarehouseTransferInventoryAllocation.class)); + verify(transfers, never()).updateById(transfer); + assertEquals(WarehouseTransferService.ALLOCATED, transfer.getStatus()); + assertEquals("RESERVED", allocation.getStatus()); + } + + @Test + void allocationRejectsPositionOutsideSelectedInboundOrder() { + WarehouseTransferOrder transfer = transfer("APPROVED"); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(sourceOrders.selectList(any())).thenReturn(List.of(sourceOrder())); + when(inventoryCommands.lockPosition("TENANT-A", 501L)).thenReturn(position(501L, 601L, 701L, 5L)); + when(inventoryCommands.lockBalance("TENANT-A", 601L)).thenReturn(balance(601L, 999L, "OTHER")); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.allocate("TENANT-A", "WTR-1", "USER-1", new WarehouseTransferRequests.Allocate("ALLOC-1", + List.of(new WarehouseTransferRequests.Allocation(501L, 701L, 5))))); + + assertEquals(409, exception.getStatusCode().value()); + verify(inventoryAllocations, never()).insert(any(WarehouseTransferInventoryAllocation.class)); + verifyNoInteractions(inventory); + } + + @Test + void selectedOrderMustBelongToTenantAndHaveInboundBinding() { + when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L); + when(orders.selectList(any())).thenReturn(List.of()); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.create("TENANT-A", "USER-1", request(false))); + + assertEquals(404, exception.getStatusCode().value()); + verify(transfers, never()).insert(any(WarehouseTransferOrder.class)); + verify(orders, never()).insert(any(LastlegOrder.class)); + } + + private static WarehouseTransferRequests.Create request(boolean needRelabel) { + return new WarehouseTransferRequests.Create("IN-1", List.of(11L), "WH-1", "ONT8", + "123 FBA Road", 5, new BigDecimal("0.2500"), "TRUCK", LocalDateTime.of(2026, 8, 20, 10, 0), "FBA-SHP-1", + "TRACK-1", "BOX-1-5", "remark", needRelabel, "key-1", + List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1"))); + } + + private static LastlegOrder historicalOrder() { + LastlegOrder order = new LastlegOrder(); + order.setId(11L); order.setCustomerId("TENANT-A"); order.setOrderNo("LMO-OLD-1"); order.setInboundCode("IN-1"); + return order; + } + + private static WarehouseTransferOrder persistedTransfer() { + WarehouseTransferOrder transfer = new WarehouseTransferOrder(); + transfer.setId(21L); transfer.setCustomerId("TENANT-A"); transfer.setTransferNo("WTR-1"); + transfer.setInboundOrderId(101L); transfer.setInboundCode("IN-1"); transfer.setLastlegOrderId(31L); + transfer.setLastlegOrderNo("LMO-TRANSFER-1"); transfer.setNeedRelabel(false); transfer.setPackageCount(5); + return transfer; + } + + private static WarehouseTransferOrder transfer(String status) { + WarehouseTransferOrder transfer = persistedTransfer(); + transfer.setTransferNo("WTR-1"); transfer.setStatus(status); transfer.setPackageCount(5); + return transfer; + } + + private static LastlegOrder tailOrder() { + LastlegOrder order = historicalOrder(); + order.setId(31L); order.setCurrentStatus("PENDING_ASSIGNMENT"); + return order; + } + + private static WarehouseTransferSourceOrder sourceOrder() { + WarehouseTransferSourceOrder source = new WarehouseTransferSourceOrder(); + source.setId(41L); source.setTransferOrderId(21L); source.setCustomerId("TENANT-A"); + source.setInboundOrderId(101L); source.setInboundCode("IN-1"); + return source; + } + + private static InventoryPosition position(long id, long balanceId, long palletId, long available) { + InventoryPosition position = new InventoryPosition(); + position.setId(id); position.setCustomerId("TENANT-A"); position.setBalanceId(balanceId); + position.setPalletId(palletId); position.setPalletNo("PALLET-1"); position.setAvailablePackages(available); + return position; + } + + 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); + return balance; + } + + private static WarehouseTransferInventoryAllocation allocation() { + WarehouseTransferInventoryAllocation allocation = new WarehouseTransferInventoryAllocation(); + allocation.setId(51L); allocation.setTransferOrderId(21L); allocation.setCustomerId("TENANT-A"); + allocation.setInventoryPositionId(501L); allocation.setInventoryBalanceId(601L); + allocation.setInboundOrderId(101L); allocation.setInboundCode("IN-1"); allocation.setPalletId(701L); + allocation.setPalletNo("PALLET-1"); allocation.setPackageCount(5); allocation.setStatus("RESERVED"); + return allocation; + } + + private static WarehouseTransferRequests.Receipt receiptRequest() { + return new WarehouseTransferRequests.Receipt(501L, "WH-DEST", "WH-D", "Destination warehouse", + "FBA-ID", "ONT8", "Amazon ONT8", 801L, "PALLET-DEST", "WTR-1", 901L, + "LOC-DEST", "RECEIVE-1", "received"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java new file mode 100644 index 000000000..724c2010e --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.trucking; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class UsTruckOrderSchemaContractTest { + @Test + void baselineContainsOrderAndImmutableHistoryTables() 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_us_truck_order")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet")); + assertTrue(sql.contains("pallet_count INT NOT NULL DEFAULT 1")); + assertTrue(sql.contains("received_pallet_count INT NOT NULL DEFAULT 0")); + assertTrue(sql.contains("receipt_idempotency_key VARCHAR(255) NULL")); + assertTrue(sql.contains("UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key)")); + assertTrue(sql.contains("KEY idx_ll_us_truck_history(customer_id,order_id,created_at)")); + } +} 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 new file mode 100644 index 000000000..e70f33db3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.trucking.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; +import com.baiyue.lastleg.trucking.service.UsTruckOrderService; +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.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 UsTruckOrderControllerTest { + @Mock UsTruckOrderService service; + private MerchantUsTruckOrderController merchant; + private PlatformUsTruckOrderController platform; + + @BeforeEach + void setUp() { + merchant = new MerchantUsTruckOrderController(); platform = new PlatformUsTruckOrderController(); + ReflectionTestUtils.setField(merchant, "service", service); ReflectionTestUtils.setField(platform, "service", service); + } + + @Test + void merchantListAlwaysUsesTrustedTenant() { + var query = query(); merchant.page(user(100L, "MERCHANT", Set.of()), query); + verify(service).page("100", query); + } + + @Test + void platformStatusChangeRequiresManagePermission() { + var request = new UsTruckOrderRequests.ChangeStatus(0L, "DISPATCHED", null); + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> platform.changeStatus(user(0L, "PLATFORM", Set.of()), "100", "TK1", request)); + assertEquals(403, error.getStatusCode().value()); + } + + @Test + void platformStatusChangeUsesExplicitTargetTenant() { + var request = new UsTruckOrderRequests.ChangeStatus(0L, "DISPATCHED", "truck assigned"); + platform.changeStatus(user(0L, "PLATFORM", Set.of(UsTruckOrderService.MANAGE_PERMISSION)), "100", "TK1", request); + verify(service).changeStatus("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/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java new file mode 100644 index 000000000..a26e52316 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java @@ -0,0 +1,142 @@ +package com.baiyue.lastleg.trucking.service; + +import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; +import com.baiyue.lastleg.trucking.entity.UsTruckOrder; +import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet; +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.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.ArrayList; +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 UsTruckOrderServiceTest { + @Mock UsTruckOrderMapper orders; + @Mock UsTruckOrderPalletMapper pallets; + @Mock UsTruckOrderStatusHistoryMapper histories; + private UsTruckOrderService service; + + @BeforeEach + void setUp() { + service = new UsTruckOrderService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "pallets", pallets); + ReflectionTestUtils.setField(service, "histories", histories); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); + org.mockito.Mockito.lenient().when(pallets.listOrder(anyString(), any())).thenReturn(List.of()); + } + + @Test + void customerOrderStartsPendingConfirmationAndUsesUsdByDefault() { + AtomicReference saved = new AtomicReference<>(); + when(orders.insert(any(UsTruckOrder.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(UsTruckOrderService.PENDING_CONFIRMATION, detail.summary().status()); + assertEquals("USD", detail.summary().currency()); + assertEquals("100", detail.summary().customerId()); + } + + @Test + void statusChangePersistsHistoryAndIncrementsVersion() { + UsTruckOrder order = order(UsTruckOrderService.PENDING_CONFIRMATION, 0L); + when(orders.lockNo("100", "TK1")).thenReturn(order); + when(orders.findNo("100", "TK1")).thenReturn(order); + when(histories.listOrder("100", 1L)).thenReturn(List.of()); + + var result = service.changeStatus("100", "TK1", "7", + new UsTruckOrderRequests.ChangeStatus(0L, "WAITING_INBOUND", "accepted")); + + assertEquals(UsTruckOrderService.WAITING_INBOUND, result.summary().status()); + assertEquals(1, result.summary().version()); + verify(histories).insert(any(UsTruckOrderStatusHistory.class)); + } + + @Test + void staleStatusChangeIsRejected() { + when(orders.lockNo("100", "TK1")).thenReturn(order(UsTruckOrderService.DISPATCHED, 2L)); + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> + service.changeStatus("100", "TK1", "7", new UsTruckOrderRequests.ChangeStatus(1L, "IN_TRANSIT", null))); + assertEquals(409, error.getStatusCode().value()); + } + + @Test + void detailIncludesBoundPallets() { + UsTruckOrder order = order(UsTruckOrderService.WAITING_INBOUND, 1L); + UsTruckOrderPallet pallet = new UsTruckOrderPallet(); + pallet.setId(10L); pallet.setPalletNo("PLT-001"); pallet.setSequenceNo(1); + pallet.setSource("SYSTEM"); pallet.setStatus("OCCUPIED"); + pallet.setWarehousePalletId(20L); pallet.setPackageCount(12); + when(orders.findNo("100", "TK1")).thenReturn(order); + when(histories.listOrder("100", 1L)).thenReturn(List.of()); + when(pallets.listOrder("100", 1L)).thenReturn(List.of(pallet)); + + var detail = service.detail("100", "TK1"); + + assertEquals(1, detail.pallets().size()); + assertEquals("PLT-001", detail.pallets().get(0).palletNo()); + assertEquals(12, detail.pallets().get(0).packageCount()); + } + + @Test + void receivingMorePalletsCreatesOverageAndKeepsDeclaredCount() { + UsTruckOrder order = order(UsTruckOrderService.WAITING_INBOUND, 0L); + order.setPalletCount(1); + when(orders.lockNo("100", "TK1")).thenReturn(order); + when(orders.findNo("100", "TK1")).thenReturn(order); + when(histories.listOrder("100", 1L)).thenReturn(List.of()); + List savedPallets = new ArrayList<>(); + when(pallets.listOrder("100", 1L)).thenAnswer(call -> List.copyOf(savedPallets)); + when(pallets.insert(any(UsTruckOrderPallet.class))).thenAnswer(call -> { + savedPallets.add(call.getArgument(0)); + return 1; + }); + + var result = service.receivePallets("100", "TK1", "7", + new UsTruckOrderRequests.ReceivePallets(0L, 3, 0, "receive-1", "actual three pallets")); + + assertEquals(1, result.summary().palletCount()); + assertEquals(3, result.summary().receivedPalletCount()); + assertEquals(UsTruckOrderService.PALLET_OVERAGE_REVIEW, result.summary().status()); + verify(pallets, org.mockito.Mockito.times(3)).insert(any(UsTruckOrderPallet.class)); + } + + private UsTruckOrderRequests.Create create(String key) { + return new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles Port", "Pier 1", "Ontario, CA", + "Warehouse A", "40HQ", 100, new BigDecimal("12600"), new BigDecimal("55.2"), + new BigDecimal("3800"), null, "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null, key); + } + + private UsTruckOrder order(String status, long version) { + UsTruckOrder order = new UsTruckOrder(); order.setId(1L); order.setCustomerId("100"); order.setOrderNo("TK1"); + order.setTransportType("PORT_PICKUP"); order.setOrigin("Los Angeles"); order.setDestination("Ontario"); + order.setContainerType("40HQ"); order.setPalletCount(1); order.setPackageCount(0); + order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setShippedPalletCount(0); + order.setStatus(status); order.setVersion(version); + order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(order.getCreatedAt()); return order; + } +} diff --git a/pom.xml b/pom.xml index 9da4d3cd5..35b79ff33 100644 --- a/pom.xml +++ b/pom.xml @@ -38,11 +38,14 @@ common-audit common-event-contract gateway + gateway-overseas auth iam order firstleg warehouse + warehouse-overseas-service + lastleg-service file print audit-service diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql new file mode 100644 index 000000000..ca5541703 --- /dev/null +++ b/scripts/db/incremental.sql @@ -0,0 +1,3553 @@ +-- Baiyue last-leg platform incremental database archive +-- Encoding: UTF-8 without BOM +-- Policy: +-- 1. Append new numbered sections; never rewrite an applied section. +-- 2. DDL must be repeatable and must preserve existing business rows. +-- 3. Every new column and index must use an information_schema guard. +-- 4. Do not place test fixtures, DELETE, TRUNCATE or business-data UPDATE here. + +-- ============================================================================ +-- 2026-08-15 / 001 +-- Event Bridge inbox and delivery-attempt baseline +-- ============================================================================ + +CREATE DATABASE IF NOT EXISTS baiyue_event_bridge + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_event_bridge; + +CREATE TABLE IF NOT EXISTS bridge_inbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + idempotency_key VARCHAR(191) NOT NULL COMMENT '接收幂等键', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户标识', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', + aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', + handover_version BIGINT NULL COMMENT '跨区交接版本', + trace_id VARCHAR(128) NOT NULL COMMENT '链路标识', + correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识', + causation_id VARCHAR(128) NULL COMMENT '上游事件标识', + occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要', + payload_json JSON NOT NULL COMMENT '事件负载', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态', + lease_owner VARCHAR(128) NULL COMMENT '消费租约持有者', + lease_until DATETIME(3) NULL COMMENT '消费租约截止时间', + retry_count INT NOT NULL DEFAULT 0 COMMENT '消费重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次消费时间', + last_error VARCHAR(1000) NULL COMMENT '最后错误', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '消费完成时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key), + KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until) +) ENGINE=InnoDB COMMENT='跨区域事件已接收箱'; + +CREATE TABLE IF NOT EXISTS bridge_delivery_attempt ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + attempt_no INT NOT NULL COMMENT '投递序号', + status VARCHAR(32) NOT NULL COMMENT '投递结果', + response_code VARCHAR(64) NULL COMMENT '对端响应码', + error_message VARCHAR(1000) NULL COMMENT '错误信息', + started_at DATETIME(3) NOT NULL COMMENT '开始时间', + finished_at DATETIME(3) NULL COMMENT '完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_bridge_attempt (event_id, attempt_no), + KEY idx_bridge_attempt_event (event_id, started_at) +) ENGINE=InnoDB COMMENT='跨区域事件投递尝试记录'; + +-- Upgrade an existing bridge_inbox without replacing payload JSON or other rows. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND column_name='lease_owner'), 'SELECT 1', 'ALTER TABLE bridge_inbox ADD COLUMN lease_owner VARCHAR(128) NULL COMMENT ''消费租约持有者'' AFTER status')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-15 / 002 +-- Warehouse locations, pallets, resource locks and UTF-8 descriptions +-- ============================================================================ + +CREATE DATABASE IF NOT EXISTS baiyue_warehouse_overseas + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_warehouse_overseas; + +CREATE TABLE IF NOT EXISTS wh_location ( + id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码', + location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码', + location_type VARCHAR(32) NOT NULL COMMENT '库位类型', + max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用', + remark VARCHAR(500) NULL COMMENT '备注', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_location_code (tenant_id,warehouse_id,location_code,deleted), + KEY idx_location_query (tenant_id,warehouse_id,zone_code,status,deleted) +) ENGINE=InnoDB COMMENT='仓库库位表:按租户和仓库维护库位及承重信息'; + +CREATE TABLE IF NOT EXISTS wh_pallet ( + id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + location_id BIGINT UNSIGNED NULL COMMENT '同仓库内绑定的库位ID,可为空', + pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码', + pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', + capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', + unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', + status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃', + remark VARCHAR(500) NULL COMMENT '备注', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_pallet_code (tenant_id,warehouse_id,pallet_code,deleted), + KEY idx_pallet_query (tenant_id,warehouse_id,status,deleted), + KEY idx_pallet_location (tenant_id,warehouse_id,location_id,deleted) +) ENGINE=InnoDB COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; + +CREATE TABLE IF NOT EXISTS wh_resource_lock ( + id BIGINT UNSIGNED NOT NULL COMMENT '资源锁ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + pallet_id BIGINT UNSIGNED NOT NULL COMMENT '锁定的托盘ID', + location_id BIGINT UNSIGNED NOT NULL COMMENT '锁定的库位ID', + business_type VARCHAR(50) NOT NULL COMMENT '调用方业务类型', + business_id VARCHAR(100) NOT NULL COMMENT '调用方业务标识', + reservation_key VARCHAR(160) NOT NULL COMMENT '调用方资源预留幂等键', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '资源锁状态:ACTIVE有效、RELEASED释放、SCRAPPED报废', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + released_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_resource_lock_reservation (tenant_id,reservation_key), + KEY idx_resource_lock_pallet (tenant_id,warehouse_id,pallet_id,status), + KEY idx_resource_lock_location (tenant_id,warehouse_id,location_id,status), + KEY idx_resource_lock_business (tenant_id,business_type,business_id) +) ENGINE=InnoDB COMMENT='仓库托盘与库位资源锁表:支持跨服务幂等预留和释放'; + +-- Fix legacy mojibake/English descriptions without changing row values. +ALTER TABLE wh_location COMMENT='仓库库位表:按租户和仓库维护库位及承重信息'; +ALTER TABLE wh_location + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', + MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码', + MODIFY COLUMN location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码', + MODIFY COLUMN location_type VARCHAR(32) NOT NULL COMMENT '库位类型', + MODIFY COLUMN max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_pallet COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; +ALTER TABLE wh_pallet + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', + MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN location_id BIGINT UNSIGNED NULL COMMENT '同仓库内绑定的库位ID,可为空', + MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码', + MODIFY COLUMN pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', + MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', + MODIFY COLUMN unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_resource_lock COMMENT='仓库托盘与库位资源锁表:支持跨服务幂等预留和释放'; +ALTER TABLE wh_resource_lock + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '资源锁ID(雪花ID)', + MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN pallet_id BIGINT UNSIGNED NOT NULL COMMENT '锁定的托盘ID', + MODIFY COLUMN location_id BIGINT UNSIGNED NOT NULL COMMENT '锁定的库位ID', + MODIFY COLUMN business_type VARCHAR(50) NOT NULL COMMENT '调用方业务类型', + MODIFY COLUMN business_id VARCHAR(100) NOT NULL COMMENT '调用方业务标识', + MODIFY COLUMN reservation_key VARCHAR(160) NOT NULL COMMENT '调用方资源预留幂等键', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '资源锁状态:ACTIVE有效、RELEASED释放、SCRAPPED报废', + MODIFY COLUMN locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + MODIFY COLUMN released_at DATETIME(3) NULL COMMENT '释放或报废时间', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间'; + +-- Guard every operational index for existing installations. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_location' AND index_name='uk_location_code'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_location_code ON wh_location(tenant_id,warehouse_id,location_code,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_location' AND index_name='idx_location_query'), 'SELECT 1', 'CREATE INDEX idx_location_query ON wh_location(tenant_id,warehouse_id,zone_code,status,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND index_name='uk_pallet_code'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_pallet_code ON wh_pallet(tenant_id,warehouse_id,pallet_code,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND index_name='idx_pallet_query'), 'SELECT 1', 'CREATE INDEX idx_pallet_query ON wh_pallet(tenant_id,warehouse_id,status,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND index_name='idx_pallet_location'), 'SELECT 1', 'CREATE INDEX idx_pallet_location ON wh_pallet(tenant_id,warehouse_id,location_id,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND index_name='uk_resource_lock_reservation'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_resource_lock_reservation ON wh_resource_lock(tenant_id,reservation_key)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND index_name='idx_resource_lock_pallet'), 'SELECT 1', 'CREATE INDEX idx_resource_lock_pallet ON wh_resource_lock(tenant_id,warehouse_id,pallet_id,status)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND index_name='idx_resource_lock_location'), 'SELECT 1', 'CREATE INDEX idx_resource_lock_location ON wh_resource_lock(tenant_id,warehouse_id,location_id,status)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND index_name='idx_resource_lock_business'), 'SELECT 1', 'CREATE INDEX idx_resource_lock_business ON wh_resource_lock(tenant_id,business_type,business_id)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-15 / 003 +-- Last-leg inbound plan, pallet allocation and PDA workflow +-- ============================================================================ + +CREATE DATABASE IF NOT EXISTS baiyue_lastleg + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan ( + id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)', + plan_no VARCHAR(32) NOT NULL COMMENT '入库单号', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '入库计划状态', + line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量', + total_volume_cbm DECIMAL(18,4) 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_inbound_plan_no (plan_no), + UNIQUE KEY uk_ll_inbound_plan_container (tenant_id,container_no) +) ENGINE=InnoDB COMMENT='尾程入库计划表:每个租户的每个集装箱生成一张入库单'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( + id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', + inbound_code 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 '目的仓名称快照', + volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '入仓码体积(立方米)', + planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量', + required_package_count INT NOT NULL DEFAULT 0 COMMENT '计划应收箱数', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_line_order (plan_id,order_id), + KEY idx_ll_inbound_line_plan (plan_id) +) ENGINE=InnoDB COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet ( + id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + pallet_no VARCHAR(64) 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 '目的仓名称快照', + capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', + allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)', + sequence_no INT NOT NULL COMMENT '计划内托盘序号', + warehouse_pallet_id BIGINT NULL COMMENT '仓库托盘ID', + warehouse_location_id BIGINT NULL COMMENT '仓库库位ID', + warehouse_resource_lock_id BIGINT NULL COMMENT '仓库资源锁ID', + assignment_status VARCHAR(32) NOT NULL DEFAULT 'UNASSIGNED' COMMENT '仓库资源分配状态', + locked_at DATETIME(3) NULL COMMENT '资源锁定时间', + actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '实际入托体积(立方米)', + actual_package_count INT NOT NULL DEFAULT 0 COMMENT '实际入托箱数', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_pallet_no (pallet_no), + UNIQUE KEY uk_ll_inbound_pallet_sequence (plan_id,sequence_no), + KEY idx_ll_inbound_pallet_plan (plan_id) +) ENGINE=InnoDB COMMENT='尾程入库计划托盘表:按目的仓隔离合托'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation ( + id BIGINT NOT NULL COMMENT '托盘分配记录ID(雪花ID)', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + line_id BIGINT NOT NULL COMMENT '入库计划明细ID', + pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '计划分配体积(立方米)', + planned_package_count INT NOT NULL DEFAULT 0 COMMENT '计划分配箱数', + counted_package_count INT NOT NULL DEFAULT 0 COMMENT '清点箱数', + picked_package_count INT NOT NULL DEFAULT 0 COMMENT '上架箱数', + actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '实际分配体积(立方米)', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_COUNT' COMMENT 'PDA作业状态', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_allocation (line_id,pallet_id), + KEY idx_ll_inbound_allocation_plan (plan_id), + KEY idx_ll_inbound_allocation_pallet (pallet_id) +) ENGINE=InnoDB COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'; + +-- Columns introduced after the initial inbound-plan release. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='required_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN required_package_count INT NOT NULL DEFAULT 0 COMMENT ''计划应收箱数''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库托盘ID''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_location_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_location_id BIGINT NULL COMMENT ''仓库库位ID''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_resource_lock_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''仓库资源锁ID''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='assignment_status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN assignment_status VARCHAR(32) NOT NULL DEFAULT ''UNASSIGNED'' COMMENT ''仓库资源分配状态''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN locked_at DATETIME(3) NULL COMMENT ''资源锁定时间''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''实际入托体积(立方米)''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_package_count INT NOT NULL DEFAULT 0 COMMENT ''实际入托箱数''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='planned_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN planned_package_count INT NOT NULL DEFAULT 0 COMMENT ''计划分配箱数''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='counted_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN counted_package_count INT NOT NULL DEFAULT 0 COMMENT ''清点箱数''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''上架箱数''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''实际分配体积(立方米)''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_COUNT'' COMMENT ''PDA作业状态''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本号''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +CREATE TABLE IF NOT EXISTS ll_inbound_pda_task ( + id BIGINT NOT NULL COMMENT 'PDA任务ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + operator_name VARCHAR(255) NULL COMMENT '操作人名称', pushed_at DATETIME(3) NULL COMMENT '推送时间', + started_at DATETIME(3) NULL COMMENT '开始时间', completed_at DATETIME(3) NULL COMMENT '完成时间', + version INT 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_inbound_pda_task_plan (tenant_id,plan_id), + UNIQUE KEY uk_ll_inbound_pda_task_no (task_no) +) ENGINE=InnoDB COMMENT='尾程入库PDA任务表'; + +CREATE TABLE IF NOT EXISTS ll_inbound_pda_operation ( + id BIGINT NOT NULL COMMENT 'PDA操作记录ID', tenant_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 '操作幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '请求摘要', request_json LONGTEXT NOT NULL COMMENT '请求JSON', + result_json LONGTEXT NOT NULL COMMENT '结果JSON', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pda_operation_key (tenant_id,idempotency_key), + KEY idx_ll_inbound_pda_operation_task (task_id), KEY idx_ll_inbound_pda_operation_allocation (allocation_id) +) ENGINE=InnoDB COMMENT='尾程入库PDA幂等操作记录'; + +CREATE TABLE IF NOT EXISTS ll_inbound_pallet_merge ( + id BIGINT NOT NULL COMMENT '合托记录ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + task_id BIGINT NOT NULL COMMENT 'PDA任务ID', merge_group_no VARCHAR(64) NOT NULL COMMENT '合托组号', + source_pallet_id BIGINT NOT NULL COMMENT '来源托盘ID', target_pallet_id BIGINT NOT NULL COMMENT '目标托盘ID', + source_allocation_id BIGINT NOT NULL COMMENT '来源分配ID', target_allocation_id BIGINT NOT NULL COMMENT '目标分配ID', + source_line_id BIGINT NOT NULL COMMENT '来源计划明细ID', target_line_id BIGINT NOT NULL COMMENT '目标计划明细ID', + source_order_id BIGINT NOT NULL COMMENT '来源订单ID', target_order_id BIGINT NOT NULL COMMENT '目标订单ID', + moved_package_count INT NOT NULL COMMENT '移动箱数', moved_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '移动体积(立方米)', + source_pallet_scrapped TINYINT NOT NULL DEFAULT 0 COMMENT '来源托盘是否报废', status VARCHAR(32) NOT NULL COMMENT '合托状态', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + KEY idx_ll_inbound_merge_task (tenant_id,task_id), KEY idx_ll_inbound_merge_group (merge_group_no) +) ENGINE=InnoDB COMMENT='尾程入库PDA合托记录'; + +-- ============================================================================ +-- 2026-08-15 / 004 +-- Upgrade an existing Event Bridge inbox with retry/lease fields and indexes +-- ============================================================================ +USE baiyue_event_bridge; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND column_name='lease_until'), 'SELECT 1', 'ALTER TABLE bridge_inbox ADD COLUMN lease_until DATETIME(3) NULL COMMENT ''消费租约截止时间'' AFTER lease_owner')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND column_name='retry_count'), 'SELECT 1', 'ALTER TABLE bridge_inbox ADD COLUMN retry_count INT NOT NULL DEFAULT 0 COMMENT ''消费重试次数'' AFTER lease_until')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND column_name='next_retry_at'), 'SELECT 1', 'ALTER TABLE bridge_inbox ADD COLUMN next_retry_at DATETIME(3) NULL COMMENT ''下次消费时间'' AFTER retry_count')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND column_name='last_error'), 'SELECT 1', 'ALTER TABLE bridge_inbox ADD COLUMN last_error VARCHAR(1000) NULL COMMENT ''最后错误'' AFTER next_retry_at')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND index_name='idx_bridge_inbox_consume'), 'SELECT 1', 'CREATE INDEX idx_bridge_inbox_consume ON bridge_inbox(status,next_retry_at,lease_until)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND index_name='uk_bridge_inbox_idempotency'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_bridge_inbox_idempotency ON bridge_inbox(idempotency_key)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-15 / 005 +-- FirstLegLoadingPlanCompleted -> last-leg order projection +-- ============================================================================ + +CREATE DATABASE IF NOT EXISTS baiyue_lastleg + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_loading_event_inbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一标识', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件负载SHA-256摘要', + payload_json JSON NOT NULL COMMENT '事件冻结负载JSON', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态:RECEIVED/PROCESSED', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + last_error VARCHAR(1000) NULL COMMENT '最近一次处理错误', + PRIMARY KEY (event_id), + UNIQUE KEY uk_ll_loading_inbox_tenant_key (tenant_id,event_type,event_id) +) ENGINE=InnoDB COMMENT='头程装柜完成事件接收幂等箱'; + +CREATE TABLE IF NOT EXISTS ll_order ( + id BIGINT NOT NULL COMMENT '雪花ID', + order_no VARCHAR(32) NOT NULL COMMENT '尾程单号,同柜入仓单共享', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型', + inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统', + source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装LOADED分片ID,建单幂等键', + root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单ID', + source_order_id VARCHAR(128) NULL COMMENT '来源订单ID', + source_order_no VARCHAR(128) NULL COMMENT '来源订单号', + root_order_id VARCHAR(128) NULL COMMENT '根订单ID', + customer_name VARCHAR(255) NULL COMMENT '货主名称', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称', + transfer_warehouse_id VARCHAR(128) NULL COMMENT '转入目的仓ID', + transfer_warehouse_code VARCHAR(128) NULL COMMENT '转入目的仓编码', + transfer_warehouse_name VARCHAR(255) NULL COMMENT '转入目的仓名称', + fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA仓库编码', + container_no VARCHAR(64) NULL COMMENT '实际集装箱号', + container_status VARCHAR(32) NULL DEFAULT '待转柜' COMMENT '货柜状态', + abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是', + detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米)', + product_quantity INT NULL COMMENT '货物数量', + volume_weight_kg DECIMAL(18,4) NULL COMMENT '体积重量(千克)', + estimated_arrival_at DATETIME(3) NULL COMMENT '预计到港时间', + actual_arrival_at DATETIME(3) NULL COMMENT '实际到港时间', + estimated_warehouse_at DATETIME(3) NULL COMMENT '预计到仓时间', + inbound_order_no VARCHAR(128) NULL COMMENT '入库单号', + outbound_order_no VARCHAR(128) NULL COMMENT '出库单号', + delivery_order_no VARCHAR(128) NULL COMMENT '派送单号', + relabel_order_no VARCHAR(128) NULL COMMENT '换标单号', + relabel_status VARCHAR(32) NULL COMMENT '换标状态', + pallet_merge_flag VARCHAR(32) NULL COMMENT '合托标记', + current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态', + private_remark VARCHAR(1000) NULL COMMENT '备注(仅自己)', + customer_remark VARCHAR(1000) NULL COMMENT '备注(客户)', + warehouse_remark VARCHAR(1000) NULL COMMENT '备注(仓库)', + source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件ID', + source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划ID', + source_loading_plan_version BIGINT NULL COMMENT '来源装柜计划版本', + source_handover_version BIGINT NULL COMMENT '交接版本', + source_payload_hash VARCHAR(71) NOT 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_order_source (tenant_id,source_region,source_inbound_id), + KEY idx_ll_order_group (tenant_id,order_no), + KEY idx_ll_order_list (tenant_id,current_status,created_at) +) ENGINE=InnoDB COMMENT='尾程订单主表'; + +CREATE TABLE IF NOT EXISTS ll_order_detail ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', + source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细ID', + sku_code VARCHAR(128) NULL COMMENT 'SKU编码', + product_name_zh VARCHAR(255) NULL COMMENT '中文品名', + product_name_en VARCHAR(255) NULL COMMENT '英文品名', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + product_quantity INT NULL COMMENT '数量', + pallet_count INT NULL COMMENT '托数', + gross_weight_kg DECIMAL(18,4) NULL COMMENT '毛重(千克)', + net_weight_kg DECIMAL(18,4) NULL COMMENT '净重(千克)', + dimensional_weight_kg DECIMAL(18,4) NULL COMMENT '体积重量(千克)', + volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米)', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', + fba_tracking_no VARCHAR(255) NULL COMMENT 'FBA跟踪号', + shipment_no VARCHAR(255) NULL COMMENT '货件编号', + shipping_mark VARCHAR(255) NULL COMMENT '唛头', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_detail_source (order_id,source_inbound_detail_id), + KEY idx_ll_detail_order (order_id) +) ENGINE=InnoDB COMMENT='尾程订单实装商品冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_loading_order_status_history ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', + event_id VARCHAR(128) NULL COMMENT '触发事件ID', + remark VARCHAR(500) NULL COMMENT '状态变更备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录时间', + PRIMARY KEY (id), + KEY idx_ll_loading_history_order (order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单状态变更历史'; + +-- Existing order installations: add transfer fields without replacing row data. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='transfer_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_id VARCHAR(128) NULL COMMENT ''转入目的仓ID'' AFTER overseas_warehouse_name')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='transfer_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_code VARCHAR(128) NULL COMMENT ''转入目的仓编码'' AFTER transfer_warehouse_id')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='transfer_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_name VARCHAR(255) NULL COMMENT ''转入目的仓名称'' AFTER transfer_warehouse_code')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- One container/loading-plan number groups multiple inbound-code rows, so order_no +-- must not be unique. Source inbound identity remains uniquely constrained. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='uk_ll_order_no' AND non_unique=0), 'ALTER TABLE ll_order DROP INDEX uk_ll_order_no', 'SELECT 1')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_group'), 'SELECT 1', 'CREATE INDEX idx_ll_order_group ON ll_order(tenant_id,order_no)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='uk_ll_order_source'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_ll_order_source ON ll_order(tenant_id,source_region,source_inbound_id)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_list'), 'SELECT 1', 'CREATE INDEX idx_ll_order_list ON ll_order(tenant_id,current_status,created_at)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND index_name='uk_ll_loading_inbox_tenant_key'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_ll_loading_inbox_tenant_key ON ll_loading_event_inbox(tenant_id,event_type,event_id)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-15 / 006 +-- Warehouse master-data descriptions and query indexes +-- ============================================================================ + +USE baiyue_warehouse_overseas; + +CREATE TABLE IF NOT EXISTS wh_warehouse ( + id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓库所属租户ID', + warehouse_code VARCHAR(50) NOT NULL COMMENT '租户内唯一仓库编码', warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', + warehouse_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '仓库类型', country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', + timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地IANA时区', contact_name VARCHAR(64) NULL COMMENT '仓库联系人', + contact_phone VARCHAR(32) NULL COMMENT '联系电话', contact_email VARCHAR(128) NULL COMMENT '联系邮箱', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '仓库状态', remark VARCHAR(500) NULL COMMENT '仓库备注', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), UNIQUE KEY uk_wh_tenant_code (tenant_id,warehouse_code,deleted), + KEY idx_wh_tenant_status (tenant_id,status,deleted), KEY idx_wh_country (tenant_id,country_code,deleted) +) ENGINE=InnoDB COMMENT='仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态'; + +CREATE TABLE IF NOT EXISTS wh_warehouse_address ( + id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', address_type VARCHAR(20) NOT NULL DEFAULT 'OPERATING' COMMENT '地址类型:OPERATING运营、RETURN退货', + country_code CHAR(2) NOT NULL COMMENT '国家二字码', state_province VARCHAR(100) NULL COMMENT '省或州', + city VARCHAR(100) NOT NULL COMMENT '城市', district VARCHAR(100) NULL COMMENT '区县', postal_code VARCHAR(20) NULL COMMENT '邮政编码', + address_line1 VARCHAR(255) NOT NULL COMMENT '主地址行', address_line2 VARCHAR(255) NULL COMMENT '补充地址行', + latitude DECIMAL(10,7) NULL COMMENT '纬度', longitude DECIMAL(10,7) NULL COMMENT '经度', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), UNIQUE KEY uk_address_type (tenant_id,warehouse_id,address_type,deleted), + KEY idx_address_warehouse (tenant_id,warehouse_id,deleted) +) ENGINE=InnoDB COMMENT='仓库地址表:保存运营、退货等不同用途的地址及可选经纬度'; + +CREATE TABLE IF NOT EXISTS wh_service_capability ( + id BIGINT UNSIGNED NOT NULL COMMENT '服务能力ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', capability_code VARCHAR(50) NOT NULL COMMENT '能力编码', + capability_name VARCHAR(100) NOT NULL COMMENT '能力名称', enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + cutoff_time TIME NULL COMMENT '当日作业截单时间', description VARCHAR(500) NULL COMMENT '能力说明', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), UNIQUE KEY uk_capability (tenant_id,warehouse_id,capability_code,deleted), + KEY idx_capability_query (tenant_id,warehouse_id,enabled,deleted) +) ENGINE=InnoDB COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间'; + +ALTER TABLE wh_warehouse COMMENT='仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态'; +ALTER TABLE wh_warehouse + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)', MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓库所属租户ID', + MODIFY COLUMN warehouse_code VARCHAR(50) NOT NULL COMMENT '租户内唯一仓库编码', MODIFY COLUMN warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', + MODIFY COLUMN warehouse_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '仓库类型', MODIFY COLUMN country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', + MODIFY COLUMN timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地IANA时区', MODIFY COLUMN contact_name VARCHAR(64) NULL COMMENT '仓库联系人', + MODIFY COLUMN contact_phone VARCHAR(32) NULL COMMENT '联系电话', MODIFY COLUMN contact_email VARCHAR(128) NULL COMMENT '联系邮箱', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '仓库状态', MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '仓库备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_warehouse_address COMMENT='仓库地址表:保存运营、退货等不同用途的地址及可选经纬度'; +ALTER TABLE wh_warehouse_address + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID', MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', MODIFY COLUMN address_type VARCHAR(20) NOT NULL DEFAULT 'OPERATING' COMMENT '地址类型:OPERATING运营、RETURN退货', + MODIFY COLUMN country_code CHAR(2) NOT NULL COMMENT '国家二字码', MODIFY COLUMN state_province VARCHAR(100) NULL COMMENT '省或州', + MODIFY COLUMN city VARCHAR(100) NOT NULL COMMENT '城市', MODIFY COLUMN district VARCHAR(100) NULL COMMENT '区县', + MODIFY COLUMN postal_code VARCHAR(20) NULL COMMENT '邮政编码', MODIFY COLUMN address_line1 VARCHAR(255) NOT NULL COMMENT '主地址行', + MODIFY COLUMN address_line2 VARCHAR(255) NULL COMMENT '补充地址行', MODIFY COLUMN latitude DECIMAL(10,7) NULL COMMENT '纬度', + MODIFY COLUMN longitude DECIMAL(10,7) NULL COMMENT '经度', MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_service_capability COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间'; +ALTER TABLE wh_service_capability + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '服务能力ID', MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', MODIFY COLUMN capability_code VARCHAR(50) NOT NULL COMMENT '能力编码:RECEIVING收货、STORAGE仓储、PICK_PACK拣包、RETURN退货', + MODIFY COLUMN capability_name VARCHAR(100) NOT NULL COMMENT '能力名称', MODIFY COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + MODIFY COLUMN cutoff_time TIME NULL COMMENT '当日作业截单时间', MODIFY COLUMN description VARCHAR(500) NULL COMMENT '能力说明', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND index_name='uk_wh_tenant_code'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_wh_tenant_code ON wh_warehouse(tenant_id,warehouse_code,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND index_name='idx_wh_tenant_status'), 'SELECT 1', 'CREATE INDEX idx_wh_tenant_status ON wh_warehouse(tenant_id,status,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND index_name='idx_wh_country'), 'SELECT 1', 'CREATE INDEX idx_wh_country ON wh_warehouse(tenant_id,country_code,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND index_name='uk_address_type'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_address_type ON wh_warehouse_address(tenant_id,warehouse_id,address_type,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND index_name='idx_address_warehouse'), 'SELECT 1', 'CREATE INDEX idx_address_warehouse ON wh_warehouse_address(tenant_id,warehouse_id,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND index_name='uk_capability'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_capability ON wh_service_capability(tenant_id,warehouse_id,capability_code,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND index_name='idx_capability_query'), 'SELECT 1', 'CREATE INDEX idx_capability_query ON wh_service_capability(tenant_id,warehouse_id,enabled,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-17 / 007 +-- Last-leg batch FBA appointments +-- ============================================================================ + +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL AFTER inbound_code')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id +SET l.fba_warehouse_code=o.fba_warehouse_code +WHERE l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL; + +CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( + id BIGINT NOT NULL COMMENT '主键ID', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + appointment_no VARCHAR(64) NOT NULL COMMENT '预约单号', + appointment_at DATETIME(3) NOT NULL COMMENT '预约时间', + appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '预约状态', + appointment_remark VARCHAR(1000) NULL COMMENT '预约备注', + fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + selected_count INT NOT NULL DEFAULT 0 COMMENT '已选明细数量', + created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_fba_appointment_no (tenant_id, appointment_no), + KEY idx_ll_fba_appointment_list (tenant_id, appointment_status, fba_warehouse_code, created_at) +) ENGINE=InnoDB COMMENT='FBA入仓预约单'; + +CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item ( + id BIGINT NOT NULL COMMENT '主键ID', + appointment_id BIGINT NOT NULL COMMENT '预约单ID', + plan_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 '入仓单号快照', + fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_fba_appointment_item_line (appointment_id, line_id), + KEY idx_ll_fba_appointment_item_plan (plan_id), + KEY idx_ll_fba_appointment_item_order (order_id) +) ENGINE=InnoDB COMMENT='FBA入仓预约明细'; + +-- ============================================================================ +-- 2026-08-17 / 008 +-- Last-leg inbound-order/package inventory balances, positions and immutable ledger +-- ============================================================================ + +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_inventory_balance ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', overseas_warehouse_code VARCHAR(128) NULL COMMENT '海外仓编码快照', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', + destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_order_no VARCHAR(128) NULL COMMENT '入仓单号快照', inbound_code VARCHAR(128) NOT 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 '收货处理中箱数', + procurement_packages BIGINT NOT NULL DEFAULT 0 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_inventory_dimension (tenant_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code), + KEY idx_ll_inventory_list (tenant_id,overseas_warehouse_id,updated_at), + KEY idx_ll_inventory_inbound (tenant_id,inbound_code) +) ENGINE=InnoDB COMMENT='入仓单维度实时库存余额'; + +CREATE TABLE IF NOT EXISTS ll_inventory_position ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目的仓编码快照', + pallet_id BIGINT NOT NULL COMMENT '托盘ID', pallet_no VARCHAR(64) NOT NULL COMMENT '托盘号', + batch_no VARCHAR(128) NOT NULL DEFAULT '' COMMENT '库存批次号', warehouse_location_id BIGINT NULL COMMENT '仓库库位ID', + warehouse_location_code VARCHAR(128) NOT 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 '已拣箱数', + receiving_packages BIGINT NOT NULL DEFAULT 0 COMMENT '收货处理中箱数', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' 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_inventory_position (balance_id,pallet_id,batch_no), + KEY idx_ll_inventory_position_pallet (tenant_id,pallet_id), + KEY idx_ll_inventory_position_location (tenant_id,overseas_warehouse_id,warehouse_location_code) +) ENGINE=InnoDB COMMENT='托盘库位批次库存明细'; + +CREATE TABLE IF NOT EXISTS ll_inventory_ledger ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', position_id BIGINT NULL COMMENT '库存仓位记录ID', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目的仓编码快照', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', + pallet_id BIGINT NULL COMMENT '托盘ID', pallet_no VARCHAR(64) NULL COMMENT '托盘号', batch_no VARCHAR(128) NULL COMMENT '库存批次号', + warehouse_location_code VARCHAR(128) NULL COMMENT '仓库库位编码', business_type VARCHAR(32) NOT NULL COMMENT '业务类型', + business_order_id BIGINT NULL COMMENT '业务单据ID', business_order_no VARCHAR(128) NULL COMMENT '业务单号', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + inbound_order_no VARCHAR(128) NULL COMMENT '入仓单号快照', outbound_order_no VARCHAR(128) NULL COMMENT '出库单号', + source_event_id VARCHAR(128) NULL COMMENT '来源事件ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + status_from VARCHAR(32) NULL COMMENT '操作前状态', status_to VARCHAR(32) NULL COMMENT '操作后状态', + before_packages BIGINT NOT NULL COMMENT '变动前箱数', delta_packages BIGINT NOT NULL COMMENT '箱数变动量', after_packages BIGINT NOT NULL COMMENT '变动后箱数', + operator_id VARCHAR(128) NULL COMMENT '操作人ID', remark VARCHAR(1000) NULL COMMENT '备注', + occurred_at DATETIME(3) NOT NULL COMMENT '业务发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_ledger_idempotency (tenant_id,idempotency_key), + KEY idx_ll_inventory_ledger_query (tenant_id,overseas_warehouse_id,occurred_at), + KEY idx_ll_inventory_ledger_inbound (tenant_id,inbound_code,occurred_at), + KEY idx_ll_inventory_ledger_business (tenant_id,business_type,business_order_no) +) ENGINE=InnoDB COMMENT='不可变库存变动流水'; + +CREATE TABLE IF NOT EXISTS ll_inventory_operation ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型', + request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', status VARCHAR(32) NOT NULL DEFAULT 'PROCESSING' COMMENT '库存命令处理状态', + result_json LONGTEXT NULL COMMENT '处理结果JSON', last_error VARCHAR(2000) 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_inventory_operation_key (tenant_id,idempotency_key), + KEY idx_ll_inventory_operation_status (tenant_id,status,updated_at) +) ENGINE=InnoDB COMMENT='库存命令幂等记录'; + +-- ============================================================================ +-- 2026-08-17 / 009 +-- PDA SKU/pallet receipt snapshots for stock-in closure +-- ============================================================================ + +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_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编码', sku_name_zh VARCHAR(255) NULL COMMENT 'SKU中文名称', source_detail_ids VARCHAR(2000) NULL COMMENT '来源明细ID集合', + 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 '实收箱数', received_quantity 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_sku (tenant_id,task_id,allocation_id,sku_code), + KEY idx_ll_stock_receipt_plan (tenant_id,plan_id,line_id), KEY idx_ll_stock_receipt_inventory (inventory_balance_id,inventory_position_id) +) ENGINE=InnoDB COMMENT='入仓PDA库存实收快照'; + +-- 2026-08-17 warehouse virtual location and generated pallet code support. +USE baiyue_warehouse_overseas; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='wh_pallet' AND column_name='pallet_code'), + 'ALTER TABLE wh_pallet MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT ''Warehouse pallet code''', 'SELECT 1')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +INSERT INTO wh_location (id, tenant_id, warehouse_id, zone_code, location_code, location_type, + max_load_kg, status, remark) +SELECT UUID_SHORT(), w.tenant_id, w.id, 'DEFAULT', CONCAT('LOC-', w.warehouse_code, '-DEFAULT'), + 'VIRTUAL', 999999999.000, 'ACTIVE', 'System default virtual location' +FROM wh_warehouse w +WHERE NOT EXISTS ( + SELECT 1 FROM wh_location l + WHERE l.tenant_id = w.tenant_id AND l.warehouse_id = w.id + AND l.location_code = CONCAT('LOC-', w.warehouse_code, '-DEFAULT') AND l.deleted = 0 +); + +-- ============================================================================ +-- 2026-08-17 / 010 +-- Last-leg pre-dispatch orders and pallet locks +-- ============================================================================ + +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_dispatch_order ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + vehicle_plate VARCHAR(64) NULL COMMENT '车牌号', driver_name VARCHAR(128) NULL COMMENT '司机姓名', + driver_phone VARCHAR(64) NULL COMMENT '司机电话', carrier_name VARCHAR(255) NULL COMMENT '承运商名称', route VARCHAR(255) NULL COMMENT '运输路线', + depart_at DATETIME(3) NULL COMMENT '发车时间', fba_appointment_no VARCHAR(64) NULL COMMENT 'FBA预约号', + fba_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'FBA预约状态', + vehicle_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '车辆预约状态', + estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_DEPARTURE' COMMENT '业务状态', + item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + created_by VARCHAR(128) NULL COMMENT '创建人ID', 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_dispatch_order_no (tenant_id,dispatch_no), + KEY idx_ll_dispatch_order_list (tenant_id,status,depart_at), KEY idx_ll_dispatch_order_vehicle (tenant_id,vehicle_plate) +) ENGINE=InnoDB COMMENT='尾程派车单'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_item ( + id BIGINT NOT NULL COMMENT '主键ID', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + plan_id BIGINT NULL COMMENT '入仓计划ID', plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', + order_id BIGINT NULL COMMENT '订单ID', order_no VARCHAR(64) NULL COMMENT '订单号', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA仓库编码', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', + source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id), + KEY idx_ll_dispatch_item_order (tenant_id,order_id), KEY idx_ll_dispatch_item_inbound (tenant_id,inbound_code), + KEY idx_ll_dispatch_item_pallet (tenant_id,plan_pallet_id,warehouse_pallet_id) +) ENGINE=InnoDB COMMENT='派车货物冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + source_pallet_key VARCHAR(160) NOT NULL COMMENT '来源托盘业务键', active_key VARCHAR(160) NULL COMMENT '生效中的资源排他键', + plan_id BIGINT NULL COMMENT '入仓计划ID', plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', + dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', released_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_dispatch_active_pallet (tenant_id,active_key), + KEY idx_ll_dispatch_pallet_lock_order (tenant_id,dispatch_order_id,status), + KEY idx_ll_dispatch_pallet_lock_source (tenant_id,source_pallet_key,status) +) ENGINE=InnoDB COMMENT='派车托盘排他锁'; + +-- ============================================================================ +-- 2026-08-17 / 011 +-- Dispatch PDA task and immutable pallet pick operations +-- ============================================================================ + +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', + task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL COMMENT '业务状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + operator_name VARCHAR(128) NULL COMMENT '操作人姓名', warehouse_id VARCHAR(128) NULL COMMENT '仓库ID', + pushed_at DATETIME(3) NOT NULL COMMENT '任务下发时间', completed_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_dispatch_pda_task_dispatch (tenant_id,dispatch_order_id), + UNIQUE KEY uk_ll_dispatch_pda_task_no (tenant_id,task_no) +) ENGINE=InnoDB COMMENT='派车PDA拣货任务'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', + dispatch_item_id BIGINT NOT NULL COMMENT '派车明细ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位记录ID', + packages BIGINT NOT NULL COMMENT '本次操作箱数', product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + picked_at DATETIME(3) NOT NULL COMMENT '拣货时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_pick_key (tenant_id,idempotency_key), + KEY idx_ll_dispatch_pda_pick_item (tenant_id,task_id,dispatch_item_id) +) ENGINE=InnoDB COMMENT='派车PDA拣货记录'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', task_id BIGINT NULL COMMENT 'PDA任务ID', + operation_type VARCHAR(32) NOT NULL COMMENT 'PDA操作类型', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + result_json LONGTEXT NULL COMMENT '处理结果JSON', status VARCHAR(32) NOT NULL COMMENT 'PDA操作处理状态', + 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_dispatch_pda_operation_key (tenant_id,idempotency_key) +) ENGINE=InnoDB COMMENT='派车PDA操作幂等记录'; + +-- ============================================================================ +-- 2026-08-17 / 012 +-- Preserve all tail-leg orders included in one merged dispatch pallet +-- ============================================================================ + +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_refs'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN order_refs VARCHAR(2000) NULL AFTER order_no')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-17 / 013 +-- Partial dispatch completion and cumulative package counters +-- ============================================================================ +USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='overseas_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN overseas_warehouse_id VARCHAR(128) NULL AFTER dispatch_no')); +PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='overseas_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL AFTER overseas_warehouse_id')); +PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='overseas_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN overseas_warehouse_id VARCHAR(128) NULL AFTER pallet_no')); +PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='overseas_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL AFTER overseas_warehouse_id')); +PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pda_task ADD COLUMN warehouse_id VARCHAR(128) NULL AFTER operator_name')); +PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 AFTER package_count')); +PREPARE dispatch_partial_stmt FROM @ddl; EXECUTE dispatch_partial_stmt; DEALLOCATE PREPARE dispatch_partial_stmt; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick_order ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', pick_id BIGINT NOT NULL COMMENT 'PDA拣货记录ID', + receipt_id BIGINT NOT NULL COMMENT '入仓实收记录ID', order_id BIGINT NOT NULL COMMENT '订单ID', packages BIGINT NOT NULL COMMENT '本次操作箱数', + product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), KEY idx_ll_dispatch_pick_order_pick (tenant_id,pick_id), + KEY idx_ll_dispatch_pick_order_receipt (tenant_id,receipt_id) +) ENGINE=InnoDB COMMENT='PDA拣货订单归属记录'; + +-- Tail order cumulative picking/shipping counters. Original package_count is immutable. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 AFTER package_count')); +PREPARE tail_order_partial_stmt FROM @ddl; EXECUTE tail_order_partial_stmt; DEALLOCATE PREPARE tail_order_partial_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='shipped_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN shipped_package_count INT NOT NULL DEFAULT 0 AFTER picked_package_count')); +PREPARE tail_order_partial_stmt FROM @ddl; EXECUTE tail_order_partial_stmt; DEALLOCATE PREPARE tail_order_partial_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='shipped_package_count'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN shipped_package_count INT NOT NULL DEFAULT 0 AFTER picked_package_count')); +PREPARE dispatch_partial_stmt FROM @ddl; EXECUTE dispatch_partial_stmt; DEALLOCATE PREPARE dispatch_partial_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='partial_completion_reason'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN partial_completion_reason VARCHAR(1000) NULL AFTER shipped_package_count')); +PREPARE dispatch_partial_stmt FROM @ddl; EXECUTE dispatch_partial_stmt; DEALLOCATE PREPARE dispatch_partial_stmt; + +-- ============================================================================ +-- 2026-08-17 / 014 +-- Dispatch transfer to outbound and delivery documents +-- ============================================================================ +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_outbound_order ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + outbound_no VARCHAR(64) NOT NULL COMMENT '出库单号', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_OUTBOUND' COMMENT '业务状态', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_outbound_no (tenant_id,outbound_no), + UNIQUE KEY uk_ll_outbound_idempotency (tenant_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch (tenant_id,dispatch_order_id), + KEY idx_ll_outbound_list (tenant_id,status,created_at) +) ENGINE=InnoDB COMMENT='尾程出库单'; + +CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', + warehouse_pallet_id BIGINT NOT NULL COMMENT '仓库实物托盘ID', pallet_no 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 '目的仓名称快照', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + UNIQUE KEY uk_ll_outbound_pallet (tenant_id,outbound_order_id,warehouse_pallet_id), + KEY idx_ll_outbound_pallet_list (tenant_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (tenant_id,destination_warehouse_code) +) ENGINE=InnoDB COMMENT='出库托盘明细'; + +CREATE TABLE IF NOT EXISTS ll_delivery_order ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + delivery_no VARCHAR(64) NOT NULL COMMENT '派送单号', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', outbound_no VARCHAR(64) NOT NULL COMMENT '出库单号', + dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', + status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '业务状态', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_delivery_no (tenant_id,delivery_no), + UNIQUE KEY uk_ll_delivery_idempotency (tenant_id,idempotency_key), UNIQUE KEY uk_ll_delivery_outbound (tenant_id,outbound_order_id), + KEY idx_ll_delivery_list (tenant_id,status,created_at) +) ENGINE=InnoDB COMMENT='尾程派送单'; + +-- ============================================================================ +-- 2026-08-17 / 015 +-- Change inventory ownership from SKU/quantity to inbound-order/package count. +-- Legacy SKU columns become nullable and quantity columns remain untouched so original data is preserved. +-- ============================================================================ +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_order_id BIGINT NULL AFTER destination_warehouse_name')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_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='inbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_order_no VARCHAR(128) NULL AFTER inbound_order_id')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_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='inbound_code'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_code VARCHAR(128) NULL AFTER inbound_order_no')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; + +-- One legacy balance row represented one SKU. Keep each row intact and give it a +-- collision-free migration identity while retaining the best known inbound snapshots. +UPDATE ll_inventory_balance b +LEFT JOIN ( + SELECT balance_id, MAX(inbound_order_no) AS inbound_order_no, MAX(inbound_code) AS inbound_code + FROM ll_inventory_ledger GROUP BY balance_id +) l ON l.balance_id=b.id +SET b.inbound_order_id=COALESCE(b.inbound_order_id,b.id), + b.inbound_order_no=COALESCE(b.inbound_order_no,l.inbound_order_no), + b.inbound_code=COALESCE(NULLIF(b.inbound_code,''),NULLIF(l.inbound_code,''),CONCAT('LEGACY-',b.id)) +WHERE b.inbound_order_id IS NULL OR b.inbound_code IS NULL OR b.inbound_code=''; + +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) NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_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='inbound_order_id'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_order_id BIGINT NOT NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_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='inbound_code'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_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 inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_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', CONCAT('ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (', IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='customer_id'), 'customer_id', 'tenant_id'), ',overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)'))); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_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_inbound'), 'SELECT 1', CONCAT('ALTER TABLE ll_inventory_balance ADD KEY idx_ll_inventory_inbound (', IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='customer_id'), 'customer_id', 'tenant_id'), ',inbound_code)'))); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_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'), 'ALTER TABLE ll_inventory_balance DROP INDEX idx_ll_inventory_sku', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD COLUMN inbound_order_id BIGINT NULL AFTER destination_warehouse_code')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +UPDATE ll_inventory_ledger SET inbound_order_id=COALESCE(inbound_order_id,business_order_id,balance_id) WHERE inbound_order_id IS NULL; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_id'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN inbound_order_id BIGINT NOT NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND index_name='idx_ll_inventory_ledger_inbound'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD KEY idx_ll_inventory_ledger_inbound (tenant_id,inbound_code,occurred_at)')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND index_name='idx_ll_inventory_ledger_sku'), 'ALTER TABLE ll_inventory_ledger DROP INDEX idx_ll_inventory_ledger_sku', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; + +-- ============================================================================ +-- 2026-08-17 / 016 +-- PDA stock receipts follow the same inbound-order/package-only inventory model. +-- Legacy SKU and quantity columns remain in place but become nullable/defaulted. +-- ============================================================================ +USE baiyue_lastleg; + +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) NULL', 'SELECT 1')); +PREPARE stock_receipt_v2_stmt FROM @ddl; EXECUTE stock_receipt_v2_stmt; DEALLOCATE PREPARE stock_receipt_v2_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='received_quantity'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN received_quantity BIGINT NULL DEFAULT 0', 'SELECT 1')); +PREPARE stock_receipt_v2_stmt FROM @ddl; EXECUTE stock_receipt_v2_stmt; DEALLOCATE PREPARE stock_receipt_v2_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'), 'ALTER TABLE ll_inbound_stock_receipt DROP INDEX uk_ll_stock_receipt_task_allocation_sku', 'SELECT 1')); +PREPARE stock_receipt_v2_stmt FROM @ddl; EXECUTE stock_receipt_v2_stmt; DEALLOCATE PREPARE stock_receipt_v2_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'), 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD UNIQUE KEY uk_ll_stock_receipt_task_allocation (tenant_id,task_id,allocation_id)')); +PREPARE stock_receipt_v2_stmt FROM @ddl; EXECUTE stock_receipt_v2_stmt; DEALLOCATE PREPARE stock_receipt_v2_stmt; + +-- ============================================================================ +-- 2026-08-17 / 017 +-- Customer relabel applications, cargo snapshots, and process status history +-- ============================================================================ +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_relabel_application ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + relabel_no VARCHAR(64) NOT NULL COMMENT '换标申请单号', lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', + customer_order_no VARCHAR(128) NOT NULL COMMENT '客户订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + label_deadline DATETIME(3) NOT NULL COMMENT '换标截止时间', contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', + contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', remark VARCHAR(1000) NULL COMMENT '备注', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_PROCESSING' COMMENT '业务状态', item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量', + total_package_count INT NOT NULL DEFAULT 0 COMMENT '总箱数', notified_package_count INT NOT NULL DEFAULT 0 COMMENT '已通知箱数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', 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) +) ENGINE=InnoDB COMMENT='客户换标申请单'; + +CREATE TABLE IF NOT EXISTS ll_relabel_item ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_image_file_id VARCHAR(128) NULL COMMENT '产品图片文件ID', + product_image_file_name VARCHAR(255) NULL COMMENT '产品图片文件名', product_image_url VARCHAR(1000) NULL COMMENT '产品图片地址', + product_name_zh VARCHAR(255) NOT NULL COMMENT '产品中文名称', total_package_count INT NOT NULL COMMENT '总箱数', notified_package_count INT NOT NULL COMMENT '已通知箱数', + shipment_no VARCHAR(128) NOT NULL COMMENT '货件编号', tracking_code VARCHAR(255) NULL COMMENT '物流跟踪编码', new_sku_code VARCHAR(128) NOT NULL COMMENT '换标后SKU编码', + label_requirement VARCHAR(1000) NULL COMMENT '换标要求', relabel_file_id VARCHAR(128) NULL COMMENT '换标文件ID', + relabel_file_name VARCHAR(255) NULL COMMENT '换标文件名', relabel_file_url VARCHAR(1000) NULL COMMENT '换标文件地址', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id) +) ENGINE=InnoDB COMMENT='换标货物冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_relabel_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', application_id BIGINT 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) +) ENGINE=InnoDB COMMENT='换标状态变更历史'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='uk_ll_relabel_application_no'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD UNIQUE KEY uk_ll_relabel_application_no (tenant_id,relabel_no)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='uk_ll_relabel_application_idempotency'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD UNIQUE KEY uk_ll_relabel_application_idempotency (tenant_id,idempotency_key)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='uk_ll_relabel_application_order'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD UNIQUE KEY uk_ll_relabel_application_order (lastleg_order_id)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='idx_ll_relabel_application_list'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD KEY idx_ll_relabel_application_list (tenant_id,status,created_at)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='idx_ll_relabel_application_customer'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD KEY idx_ll_relabel_application_customer (tenant_id,customer_order_no)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='idx_ll_relabel_application_warehouse'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD KEY idx_ll_relabel_application_warehouse (tenant_id,overseas_warehouse_id)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_application'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_application (tenant_id,application_id)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_sku'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_sku (tenant_id,sku_code)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND index_name='idx_ll_relabel_status_history_application'), 'SELECT 1', 'ALTER TABLE ll_relabel_status_history ADD KEY idx_ll_relabel_status_history_application (tenant_id,application_id,created_at)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; + +-- ============================================================================ +-- 2026-08-17 / 018 +-- Existing-table correction: apply schema changes with ADD/UPDATE/ALTER guards +-- ============================================================================ +-- Sections 008-017 keep CREATE TABLE definitions for fresh databases. This +-- section is the authoritative path for databases that already contain those +-- tables. Legacy SKU/quantity columns are retained and made nullable; no +-- business rows or Outbox rows are deleted. +USE baiyue_lastleg; + +-- Tail orders: cumulative PDA picking/shipping counters. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已拣箱数'' AFTER package_count')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='shipped_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN shipped_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已出库箱数'' AFTER picked_package_count')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; + +-- Inventory balance: add inbound-order identity without dropping legacy SKU data. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_order_id BIGINT NULL COMMENT ''入仓单ID'' AFTER destination_warehouse_name')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_order_no VARCHAR(128) NULL COMMENT ''入库单号快照'' AFTER inbound_order_id')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_code'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_code VARCHAR(128) NULL COMMENT ''入仓码快照'' AFTER inbound_order_no')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +UPDATE ll_inventory_balance +SET inbound_order_id = COALESCE(inbound_order_id, id), + inbound_code = COALESCE(NULLIF(inbound_code, ''), CONCAT('LEGACY-', id)) +WHERE inbound_order_id IS NULL OR inbound_code IS NULL OR inbound_code = ''; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_id'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_order_id BIGINT NOT NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_code'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +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 migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +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', CONCAT('ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (', IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='customer_id'), 'customer_id', 'tenant_id'), ',overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)'))); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +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'), 'ALTER TABLE ll_inventory_balance DROP INDEX idx_ll_inventory_sku', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +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_inbound'), 'SELECT 1', CONCAT('ALTER TABLE ll_inventory_balance ADD KEY idx_ll_inventory_inbound (', IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='customer_id'), 'customer_id', 'tenant_id'), ',inbound_code)'))); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; + +-- Inventory positions and ledger: preserve SKU columns, add inbound identity. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD COLUMN inbound_order_id BIGINT NULL COMMENT ''入仓单ID'' AFTER destination_warehouse_code')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +UPDATE ll_inventory_ledger +SET inbound_order_id = COALESCE(inbound_order_id, business_order_id, balance_id) +WHERE inbound_order_id IS NULL; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_id'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN inbound_order_id BIGINT NOT NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND index_name='idx_ll_inventory_ledger_sku'), 'ALTER TABLE ll_inventory_ledger DROP INDEX idx_ll_inventory_ledger_sku', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND index_name='idx_ll_inventory_ledger_inbound'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD KEY idx_ll_inventory_ledger_inbound (tenant_id,inbound_code,occurred_at)')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; + +-- Stock receipts: keep legacy SKU/quantity snapshots but allow inbound-only rows. +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) NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +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='received_quantity'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN received_quantity BIGINT NULL DEFAULT 0', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +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'), 'ALTER TABLE ll_inbound_stock_receipt DROP INDEX uk_ll_stock_receipt_task_allocation_sku', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +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'), 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD UNIQUE KEY uk_ll_stock_receipt_task_allocation (tenant_id,task_id,allocation_id)')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; + +-- Dispatch/PDA fields are also ADD operations for existing installations. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已拣箱数'' AFTER package_count')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='shipped_package_count'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN shipped_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已出库箱数'' AFTER picked_package_count')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='partial_completion_reason'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN partial_completion_reason VARCHAR(1000) NULL COMMENT ''部分完成原因'' AFTER shipped_package_count')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; + +-- No DROP COLUMN is included. Existing columns and business rows remain recoverable. + +-- ============================================================================ +-- 2026-08-17 / 019 +-- Warehouse transfer orders with historical customer-order/inbound bindings. +-- ============================================================================ +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + transfer_no VARCHAR(64) NOT NULL COMMENT '转仓单号', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NOT NULL COMMENT '尾程订单号', + operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '作业仓库ID', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + delivery_address VARCHAR(1000) NOT NULL COMMENT '派送地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '派送方式', + estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计到达时间', fba_shipment_no VARCHAR(128) NOT NULL COMMENT 'FBA货件编号', + tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注', + need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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) +) ENGINE=InnoDB COMMENT='客户转仓单'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_source_order ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + source_lastleg_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID', source_lastleg_order_no VARCHAR(32) NOT NULL COMMENT '来源尾程订单号', + inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id) +) ENGINE=InnoDB COMMENT='转仓来源历史订单绑定'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_item ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NOT NULL COMMENT '产品中文名称', + package_count INT NOT NULL COMMENT '箱数', shipping_mark VARCHAR(255) NULL COMMENT '运输唛头', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id) +) ENGINE=InnoDB COMMENT='转仓货物冻结明细'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND index_name='uk_ll_wh_transfer_no'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD UNIQUE KEY uk_ll_wh_transfer_no (tenant_id,transfer_no)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND index_name='uk_ll_wh_transfer_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD UNIQUE KEY uk_ll_wh_transfer_key (tenant_id,idempotency_key)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND index_name='uk_ll_wh_transfer_tail_order'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD UNIQUE KEY uk_ll_wh_transfer_tail_order (lastleg_order_id)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND index_name='idx_ll_wh_transfer_list'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD KEY idx_ll_wh_transfer_list (tenant_id,status,created_at)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND index_name='idx_ll_wh_transfer_inbound'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD KEY idx_ll_wh_transfer_inbound (tenant_id,inbound_code)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND index_name='uk_ll_wh_transfer_source'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_source_order ADD UNIQUE KEY uk_ll_wh_transfer_source (transfer_order_id,source_lastleg_order_id)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND index_name='idx_ll_wh_transfer_source_list'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_source_order ADD KEY idx_ll_wh_transfer_source_list (tenant_id,transfer_order_id)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND index_name='idx_ll_wh_transfer_item_list'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_item ADD KEY idx_ll_wh_transfer_item_list (tenant_id,transfer_order_id)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND index_name='idx_ll_wh_transfer_item_sku'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_item ADD KEY idx_ll_wh_transfer_item_sku (tenant_id,sku_code)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; + +-- 2026-08-17 / customer naming and one-click dropship carton inventory. +-- Rename only existing columns; deployed data and index definitions remain intact. +DELIMITER // +CREATE PROCEDURE migrate_tenant_columns_to_customer() +BEGIN + DECLARE done INT DEFAULT FALSE; + DECLARE table_name_value VARCHAR(128); + DECLARE column_cursor CURSOR FOR + SELECT c.table_name FROM information_schema.columns c + WHERE c.table_schema=DATABASE() AND c.column_name='tenant_id' + AND NOT EXISTS (SELECT 1 FROM information_schema.columns c2 + WHERE c2.table_schema=c.table_schema AND c2.table_name=c.table_name AND c2.column_name='customer_id'); + DECLARE CONTINUE HANDLER FOR NOT FOUND SET done=TRUE; + OPEN column_cursor; + rename_loop: LOOP + FETCH column_cursor INTO table_name_value; + IF done THEN LEAVE rename_loop; END IF; + SET @rename_sql=CONCAT('ALTER TABLE `', table_name_value, '` RENAME COLUMN tenant_id TO customer_id'); + PREPARE rename_statement FROM @rename_sql; + EXECUTE rename_statement; + DEALLOCATE PREPARE rename_statement; + END LOOP; + CLOSE column_cursor; +END// +DELIMITER ; +CALL migrate_tenant_columns_to_customer(); +DROP PROCEDURE migrate_tenant_columns_to_customer; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='sku_code'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD COLUMN sku_code VARCHAR(128) NULL AFTER batch_no')); +PREPARE inventory_sku_020 FROM @ddl; EXECUTE inventory_sku_020; DEALLOCATE PREPARE inventory_sku_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='carton_mark'), 'SELECT 1', IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='carton_no'), 'ALTER TABLE ll_inventory_ledger RENAME COLUMN carton_no TO carton_mark', 'ALTER TABLE ll_inventory_ledger ADD COLUMN carton_mark VARCHAR(128) NULL AFTER sku_code'))); +PREPARE inventory_carton_020 FROM @ddl; EXECUTE inventory_carton_020; DEALLOCATE PREPARE inventory_carton_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='carton_packages'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD COLUMN carton_packages BIGINT NULL AFTER carton_mark')); +PREPARE inventory_carton_qty_020 FROM @ddl; EXECUTE inventory_carton_qty_020; DEALLOCATE PREPARE inventory_carton_qty_020; + +CREATE TABLE IF NOT EXISTS ll_dropship_order_profile ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', + warehouse_pallet_id BIGINT NOT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号', inventory_position_id BIGINT NULL COMMENT '库存仓位记录ID', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '业务状态', idempotency_key VARCHAR(255) NOT 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_dropship_profile_order(customer_id,order_id), UNIQUE KEY uk_ll_dropship_profile_key(customer_id,idempotency_key) +) ENGINE=InnoDB COMMENT='一件代发托盘档案'; +CREATE TABLE IF NOT EXISTS ll_dropship_carton ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', + warehouse_pallet_id BIGINT NOT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', carton_sequence INT NOT NULL COMMENT '箱唛序号', + status VARCHAR(32) NOT NULL DEFAULT 'AVAILABLE' COMMENT '业务状态', outbound_order_no VARCHAR(128) 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_dropship_carton(customer_id,order_id,carton_mark), KEY idx_ll_dropship_carton_status(customer_id,status), + KEY idx_ll_dropship_carton_sku(customer_id,sku_code,status) +) ENGINE=InnoDB COMMENT='一件代发箱唛明细'; +CREATE TABLE IF NOT EXISTS ll_dropship_sku_inventory ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', package_count BIGINT NOT NULL COMMENT '箱数', product_quantity BIGINT NOT 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_dropship_sku_inventory(customer_id,order_id,sku_code) +) ENGINE=InnoDB COMMENT='一件代发SKU库存汇总'; +CREATE TABLE IF NOT EXISTS ll_dropship_carton_ledger ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', + carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型', + before_packages BIGINT NOT NULL COMMENT '变动前箱数', delta_packages BIGINT NOT NULL COMMENT '箱数变动量', after_packages BIGINT NOT NULL COMMENT '变动后箱数', + business_order_no VARCHAR(128) NULL COMMENT '业务单号', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', remark VARCHAR(1000) NULL COMMENT '备注', occurred_at DATETIME(3) NOT NULL COMMENT '业务发生时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_ledger_key(customer_id,idempotency_key), + KEY idx_ll_dropship_ledger_sku(customer_id,sku_code,carton_mark) +) ENGINE=InnoDB COMMENT='一件代发箱唛库存流水'; +-- Dropship profile stores the immutable first-leg pallet number snapshot. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='source_pallet_no'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD COLUMN source_pallet_no VARCHAR(128) NULL COMMENT ''Pallet number snapshot supplied by first-leg handover'' AFTER pallet_no')); +PREPARE dropship_source_pallet_no_stmt FROM @ddl; EXECUTE dropship_source_pallet_no_stmt; DEALLOCATE PREPARE dropship_source_pallet_no_stmt; + +-- Explicit index upgrades for existing installations. CREATE TABLE IF NOT EXISTS +-- does not add indexes to tables that already exist. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_container'), 'SELECT 1', 'ALTER TABLE ll_order ADD KEY idx_ll_order_container (customer_id,container_no)')); +PREPARE lastleg_indexes_020 FROM @ddl; EXECUTE lastleg_indexes_020; DEALLOCATE PREPARE lastleg_indexes_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND index_name='uk_ll_dropship_profile_key'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD UNIQUE KEY uk_ll_dropship_profile_key (customer_id,idempotency_key)')); +PREPARE lastleg_indexes_020 FROM @ddl; EXECUTE lastleg_indexes_020; DEALLOCATE PREPARE lastleg_indexes_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND index_name='uk_ll_dropship_carton'), 'SELECT 1', 'ALTER TABLE ll_dropship_carton ADD UNIQUE KEY uk_ll_dropship_carton (customer_id,order_id,carton_mark)')); +PREPARE lastleg_indexes_020 FROM @ddl; EXECUTE lastleg_indexes_020; DEALLOCATE PREPARE lastleg_indexes_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND index_name='idx_ll_dropship_carton_status'), 'SELECT 1', 'ALTER TABLE ll_dropship_carton ADD KEY idx_ll_dropship_carton_status (customer_id,status)')); +PREPARE lastleg_indexes_020 FROM @ddl; EXECUTE lastleg_indexes_020; DEALLOCATE PREPARE lastleg_indexes_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND index_name='idx_ll_dropship_carton_sku'), 'SELECT 1', 'ALTER TABLE ll_dropship_carton ADD KEY idx_ll_dropship_carton_sku (customer_id,sku_code,status)')); +PREPARE lastleg_indexes_020 FROM @ddl; EXECUTE lastleg_indexes_020; DEALLOCATE PREPARE lastleg_indexes_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND index_name='uk_ll_dropship_ledger_key'), 'SELECT 1', 'ALTER TABLE ll_dropship_carton_ledger ADD UNIQUE KEY uk_ll_dropship_ledger_key (customer_id,idempotency_key)')); +PREPARE lastleg_indexes_020 FROM @ddl; EXECUTE lastleg_indexes_020; DEALLOCATE PREPARE lastleg_indexes_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND index_name='idx_ll_dropship_ledger_sku'), 'SELECT 1', 'ALTER TABLE ll_dropship_carton_ledger ADD KEY idx_ll_dropship_ledger_sku (customer_id,sku_code,carton_mark)')); +PREPARE lastleg_indexes_020 FROM @ddl; EXECUTE lastleg_indexes_020; DEALLOCATE PREPARE lastleg_indexes_020; + +-- ============================================================================ +-- 2026-08-18 / dropship multi-pallet cardinality +-- One one-click dropship inbound order may own multiple source pallets. +-- ============================================================================ +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND index_name='uk_ll_dropship_profile_order'), 'ALTER TABLE ll_dropship_order_profile DROP INDEX uk_ll_dropship_profile_order', 'SELECT 1')); +PREPARE dropship_drop_single_pallet_stmt FROM @ddl; EXECUTE dropship_drop_single_pallet_stmt; DEALLOCATE PREPARE dropship_drop_single_pallet_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND index_name='uk_ll_dropship_profile_order_pallet'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD UNIQUE KEY uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no)')); +PREPARE dropship_add_order_pallet_stmt FROM @ddl; EXECUTE dropship_add_order_pallet_stmt; DEALLOCATE PREPARE dropship_add_order_pallet_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND index_name='idx_ll_dropship_profile_order'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD KEY idx_ll_dropship_profile_order(customer_id,order_id)')); +PREPARE dropship_add_order_idx_stmt FROM @ddl; EXECUTE dropship_add_order_idx_stmt; DEALLOCATE PREPARE dropship_add_order_idx_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND index_name='uk_ll_dropship_sku_inventory'), 'ALTER TABLE ll_dropship_sku_inventory DROP INDEX uk_ll_dropship_sku_inventory', 'SELECT 1')); +PREPARE dropship_drop_order_sku_stmt FROM @ddl; EXECUTE dropship_drop_order_sku_stmt; DEALLOCATE PREPARE dropship_drop_order_sku_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND index_name='uk_ll_dropship_sku_profile'), 'SELECT 1', 'ALTER TABLE ll_dropship_sku_inventory ADD UNIQUE KEY uk_ll_dropship_sku_profile(profile_id,sku_code)')); +PREPARE dropship_add_profile_sku_stmt FROM @ddl; EXECUTE dropship_add_profile_sku_stmt; DEALLOCATE PREPARE dropship_add_profile_sku_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND index_name='idx_ll_dropship_sku_order'), 'SELECT 1', 'ALTER TABLE ll_dropship_sku_inventory ADD KEY idx_ll_dropship_sku_order(customer_id,order_id,sku_code)')); +PREPARE dropship_add_order_sku_idx_stmt FROM @ddl; EXECUTE dropship_add_order_sku_idx_stmt; DEALLOCATE PREPARE dropship_add_order_sku_idx_stmt; + +-- Dropship event snapshots are not physical stock until overseas PDA stock-in completes. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='warehouse_pallet_id' AND is_nullable='NO'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN warehouse_pallet_id BIGINT NULL', 'SELECT 1')); +PREPARE dropship_profile_pallet_nullable_stmt FROM @ddl; EXECUTE dropship_profile_pallet_nullable_stmt; DEALLOCATE PREPARE dropship_profile_pallet_nullable_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='pallet_no' AND is_nullable='NO'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN pallet_no VARCHAR(128) NULL', 'SELECT 1')); +PREPARE dropship_profile_no_nullable_stmt FROM @ddl; EXECUTE dropship_profile_no_nullable_stmt; DEALLOCATE PREPARE dropship_profile_no_nullable_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='warehouse_pallet_id' AND is_nullable='NO'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN warehouse_pallet_id BIGINT NULL', 'SELECT 1')); +PREPARE dropship_carton_pallet_nullable_stmt FROM @ddl; EXECUTE dropship_carton_pallet_nullable_stmt; DEALLOCATE PREPARE dropship_carton_pallet_nullable_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='pallet_no' AND is_nullable='NO'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN pallet_no VARCHAR(128) NULL', 'SELECT 1')); +PREPARE dropship_carton_no_nullable_stmt FROM @ddl; EXECUTE dropship_carton_no_nullable_stmt; DEALLOCATE PREPARE dropship_carton_no_nullable_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='status' AND column_default<>'PENDING_INBOUND'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_INBOUND''', 'SELECT 1')); +PREPARE dropship_profile_pending_default_stmt FROM @ddl; EXECUTE dropship_profile_pending_default_stmt; DEALLOCATE PREPARE dropship_profile_pending_default_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='status' AND column_default<>'PENDING_INBOUND'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_INBOUND''', 'SELECT 1')); +PREPARE dropship_carton_pending_default_stmt FROM @ddl; EXECUTE dropship_carton_pending_default_stmt; DEALLOCATE PREPARE dropship_carton_pending_default_stmt; + +-- ============================================================================ +-- 2026-08-18 / 021 cargo discard application, carton reservation and approval. +-- ============================================================================ +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_application ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', inbound_codes VARCHAR(2000) NOT NULL COMMENT '入仓单号集合', + total_packages INT NOT NULL COMMENT '总箱数', fee_per_package DECIMAL(18,2) NOT NULL COMMENT '每箱处理费用', + total_fee DECIMAL(18,2) NOT NULL COMMENT '费用合计', currency CHAR(3) NOT NULL DEFAULT 'USD' 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 '审核时间', 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_cargo_discard_no(customer_id,discard_no), + UNIQUE KEY uk_ll_cargo_discard_key(customer_id,idempotency_key), + KEY idx_ll_cargo_discard_list(customer_id,status,created_at), + KEY idx_ll_cargo_discard_warehouse(customer_id,overseas_warehouse_id,created_at) +) ENGINE=InnoDB COMMENT='货物废弃申请单'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_item ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', + sku_inventory_id BIGINT NOT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', + available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '废弃箱数', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code), + KEY idx_ll_cargo_discard_item_list(customer_id,application_id), + KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code) +) ENGINE=InnoDB COMMENT='货物废弃SKU明细'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id), + KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id), + KEY idx_ll_cargo_discard_carton_identity(customer_id,carton_id) +) ENGINE=InnoDB COMMENT='货物废弃锁定箱明细'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', application_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_cargo_discard_history(customer_id,application_id,created_at) +) ENGINE=InnoDB COMMENT='货物废弃审批历史'; + +-- ============================================================================ +-- 2026-08-18 / 022 overseas procurement approval, inventory and outbound flow. +-- ============================================================================ +USE baiyue_lastleg; + +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 '累计入库数量', outbound_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_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', + 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_overseas_procurement_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='海外采购审核历史'; + +-- 2026-08-18 / 023 US domestic trucking customer and platform workflow. +USE baiyue_lastleg; + +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) 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), + 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_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='美国卡车订单状态历史'; + +-- ============================================================================ +-- 2026-08-18 / 024 warehouse transfer inventory allocation, outbound and receipt. +-- Creation and approval remain document-only. Inventory moves on outbound and receipt. +-- ============================================================================ +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='allocation_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN allocation_idempotency_key VARCHAR(255) NULL COMMENT ''库存分配幂等键'' AFTER status')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='allocation_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN allocation_request_hash CHAR(64) NULL COMMENT ''库存分配请求哈希'' AFTER allocation_idempotency_key')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_idempotency_key VARCHAR(255) NULL COMMENT ''出库幂等键'' AFTER allocation_request_hash')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_request_hash CHAR(64) NULL COMMENT ''出库请求哈希'' AFTER outbound_idempotency_key')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_at DATETIME(3) NULL COMMENT ''出库时间'' AFTER outbound_request_hash')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_inventory_allocation ( + id BIGINT NOT NULL COMMENT '雪花主键', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + inventory_position_id BIGINT NOT NULL COMMENT '来源库存仓位记录ID', inventory_balance_id BIGINT NOT NULL COMMENT '来源库存余额记录ID', + inbound_order_id BIGINT NOT NULL COMMENT '来源入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '来源入仓单号快照', + pallet_id BIGINT NOT NULL COMMENT '来源托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '来源托盘号快照', package_count INT NOT NULL COMMENT '分配箱数', + status VARCHAR(32) NOT NULL DEFAULT 'RESERVED' COMMENT '分配状态', active_position_key VARCHAR(255) NULL COMMENT '生效中的仓位排他键', + allocated_at DATETIME(3) NOT NULL COMMENT '分配时间', shipped_at DATETIME(3) 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) +) ENGINE=InnoDB COMMENT='转仓单来源库存分配记录'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_receipt ( + id BIGINT NOT NULL COMMENT '雪花主键', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', allocation_id BIGINT NOT NULL COMMENT '库存分配记录ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', source_inventory_position_id BIGINT NOT NULL COMMENT '来源库存仓位记录ID', + destination_inventory_balance_id BIGINT NOT NULL COMMENT '目标库存余额记录ID', destination_inventory_position_id BIGINT NOT NULL COMMENT '目标库存仓位记录ID', + package_count INT NOT NULL COMMENT '收货箱数', idempotency_key VARCHAR(255) NOT NULL COMMENT '收货幂等键', request_hash CHAR(64) NOT NULL COMMENT '收货请求哈希', + inventory_operation_key VARCHAR(255) NOT NULL COMMENT '库存操作幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', + received_at DATETIME(3) NOT NULL COMMENT '收货时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id) +) ENGINE=InnoDB COMMENT='转仓来源仓位的目标库存收货记录'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND index_name='uk_ll_wh_transfer_allocation_position'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_inventory_allocation ADD UNIQUE KEY uk_ll_wh_transfer_allocation_position(transfer_order_id,inventory_position_id)')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND index_name='uk_ll_wh_transfer_active_position'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_inventory_allocation ADD UNIQUE KEY uk_ll_wh_transfer_active_position(customer_id,active_position_key)')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND index_name='idx_ll_wh_transfer_allocation_list'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_inventory_allocation ADD KEY idx_ll_wh_transfer_allocation_list(customer_id,transfer_order_id,status)')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND index_name='uk_ll_wh_transfer_receipt_source'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_receipt ADD UNIQUE KEY uk_ll_wh_transfer_receipt_source(transfer_order_id,source_inventory_position_id)')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND index_name='uk_ll_wh_transfer_receipt_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_receipt ADD UNIQUE KEY uk_ll_wh_transfer_receipt_key(customer_id,idempotency_key)')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND index_name='uk_ll_wh_transfer_receipt_operation'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_receipt ADD UNIQUE KEY uk_ll_wh_transfer_receipt_operation(customer_id,inventory_operation_key)')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND index_name='idx_ll_wh_transfer_receipt_list'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_receipt ADD KEY idx_ll_wh_transfer_receipt_list(customer_id,transfer_order_id,received_at)')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; + +-- 2026-08-18 / 024 Pallet-first US domestic trucking receipt workflow. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='transport_type' AND is_nullable='NO'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN transport_type VARCHAR(32) NULL COMMENT ''运输类型''', 'SELECT 1')); +PREPARE us_truck_transport_type_stmt FROM @ddl; EXECUTE us_truck_transport_type_stmt; DEALLOCATE PREPARE us_truck_transport_type_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='container_type' AND is_nullable='NO'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN container_type VARCHAR(32) NULL COMMENT ''历史兼容设备类型,零担托盘业务不采集''', 'SELECT 1')); +PREPARE us_truck_container_type_stmt FROM @ddl; EXECUTE us_truck_container_type_stmt; DEALLOCATE PREPARE us_truck_container_type_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='pallet_count'), 'SELECT 1', 'ALTER TABLE ll_us_truck_order ADD COLUMN pallet_count INT NOT NULL DEFAULT 1 COMMENT ''客户申报托盘数'' AFTER container_type')); +PREPARE us_truck_pallet_count_stmt FROM @ddl; EXECUTE us_truck_pallet_count_stmt; DEALLOCATE PREPARE us_truck_pallet_count_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='received_pallet_count'), 'SELECT 1', 'ALTER TABLE ll_us_truck_order ADD COLUMN received_pallet_count INT NOT NULL DEFAULT 0 COMMENT ''PDA实收托盘数'' AFTER package_count')); +PREPARE us_truck_received_stmt FROM @ddl; EXECUTE us_truck_received_stmt; DEALLOCATE PREPARE us_truck_received_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='allocated_pallet_count'), 'SELECT 1', 'ALTER TABLE ll_us_truck_order ADD COLUMN allocated_pallet_count INT NOT NULL DEFAULT 0 COMMENT ''已分配到合车行程的托盘数'' AFTER received_pallet_count')); +PREPARE us_truck_allocated_stmt FROM @ddl; EXECUTE us_truck_allocated_stmt; DEALLOCATE PREPARE us_truck_allocated_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='shipped_pallet_count'), 'SELECT 1', 'ALTER TABLE ll_us_truck_order ADD COLUMN shipped_pallet_count INT NOT NULL DEFAULT 0 COMMENT ''已出库托盘数'' AFTER allocated_pallet_count')); +PREPARE us_truck_shipped_stmt FROM @ddl; EXECUTE us_truck_shipped_stmt; DEALLOCATE PREPARE us_truck_shipped_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='receipt_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_us_truck_order ADD COLUMN receipt_idempotency_key VARCHAR(255) NULL COMMENT ''实物收货幂等键'' AFTER remark')); +PREPARE us_truck_receipt_key_stmt FROM @ddl; EXECUTE us_truck_receipt_key_stmt; DEALLOCATE PREPARE us_truck_receipt_key_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='receipt_request_hash'), 'SELECT 1', 'ALTER TABLE ll_us_truck_order ADD COLUMN receipt_request_hash CHAR(64) NULL COMMENT ''实物收货请求哈希'' AFTER receipt_idempotency_key')); +PREPARE us_truck_receipt_hash_stmt FROM @ddl; EXECUTE us_truck_receipt_hash_stmt; DEALLOCATE PREPARE us_truck_receipt_hash_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND index_name='uk_ll_us_truck_receipt_key'), 'SELECT 1', 'ALTER TABLE ll_us_truck_order ADD UNIQUE KEY uk_ll_us_truck_receipt_key(customer_id,receipt_idempotency_key)')); +PREPARE us_truck_receipt_index_stmt FROM @ddl; EXECUTE us_truck_receipt_index_stmt; DEALLOCATE PREPARE us_truck_receipt_index_stmt; +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='美国卡车订单托盘实收记录';+ +-- ============================================================================ +-- 2026-08-18 / 025 Chinese table and column comments for Lastleg and Warehouse. +-- Idempotently updates metadata for databases where CREATE TABLE already ran. +-- ============================================================================ + +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND COALESCE(table_comment,'') <> '头程装柜完成事件接收幂等箱'), 'ALTER TABLE ll_loading_event_inbox 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_loading_event_inbox' AND column_name='event_id' AND COALESCE(column_comment,'') <> '事件全局唯一标识'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN event_id 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_loading_event_inbox' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户/客户账号标识'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN customer_id 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_loading_event_inbox' AND column_name='event_type' AND COALESCE(column_comment,'') <> '事件类型'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN event_type VARCHAR(64) 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_loading_event_inbox' AND column_name='event_version' AND COALESCE(column_comment,'') <> '事件契约版本'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN event_version INT 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_loading_event_inbox' AND column_name='payload_hash' AND COALESCE(column_comment,'') <> '规范化事件负载 SHA-256 摘要'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT ''规范化事件负载 SHA-256 摘要''', '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_loading_event_inbox' AND column_name='payload_json' AND COALESCE(column_comment,'') <> '事件冻结负载 JSON'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN payload_json JSON NOT NULL COMMENT ''事件冻结负载 JSON''', '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_loading_event_inbox' AND column_name='status' AND COALESCE(column_comment,'') <> '消费状态:RECEIVED/PROCESSED'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''RECEIVED'' COMMENT ''消费状态:RECEIVED/PROCESSED''', '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_loading_event_inbox' AND column_name='received_at' AND COALESCE(column_comment,'') <> '接收时间'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN received_at DATETIME(3) NOT NULL DEFAULT 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_loading_event_inbox' AND column_name='processed_at' AND COALESCE(column_comment,'') <> '处理完成时间'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN processed_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_loading_event_inbox' AND column_name='last_error' AND COALESCE(column_comment,'') <> '最近一次处理错误'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN last_error VARCHAR(1000) 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.tables WHERE table_schema=DATABASE() AND table_name='ll_order' AND COALESCE(table_comment,'') <> '尾程订单主表'), 'ALTER TABLE ll_order 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='id' AND COALESCE(column_comment,'') <> '雪花 ID'), 'ALTER TABLE ll_order MODIFY COLUMN id BIGINT 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='order_no' AND COALESCE(column_comment,'') <> '尾程单号'), 'ALTER TABLE ll_order MODIFY COLUMN order_no VARCHAR(32) 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_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户/客户账号标识'), 'ALTER TABLE ll_order MODIFY COLUMN customer_id 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_order' AND column_name='order_type' AND COALESCE(column_comment,'') <> '订单类型'), 'ALTER TABLE ll_order MODIFY COLUMN order_type VARCHAR(32) NOT NULL DEFAULT ''FIRST_LEG'' 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='inbound_code' AND COALESCE(column_comment,'') <> '入仓码'), 'ALTER TABLE ll_order MODIFY COLUMN inbound_code 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_order' AND column_name='source_region' AND COALESCE(column_comment,'') <> '来源区域/系统'), 'ALTER TABLE ll_order MODIFY COLUMN source_region VARCHAR(32) 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_order' AND column_name='source_inbound_id' AND COALESCE(column_comment,'') <> '最终实装 LOADED 分片 ID,建单幂等键'), 'ALTER TABLE ll_order MODIFY COLUMN source_inbound_id VARCHAR(128) NOT NULL COMMENT ''最终实装 LOADED 分片 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='root_inbound_id' AND COALESCE(column_comment,'') <> '根入仓单 ID'), 'ALTER TABLE ll_order MODIFY COLUMN root_inbound_id VARCHAR(128) 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_order_id' AND COALESCE(column_comment,'') <> '来源订单 ID'), 'ALTER TABLE ll_order MODIFY COLUMN source_order_id VARCHAR(128) 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_order_no' AND COALESCE(column_comment,'') <> '来源订单号'), 'ALTER TABLE ll_order MODIFY COLUMN source_order_no VARCHAR(128) 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='root_order_id' AND COALESCE(column_comment,'') <> '根订单 ID'), 'ALTER TABLE ll_order MODIFY COLUMN root_order_id VARCHAR(128) 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='customer_name' AND COALESCE(column_comment,'') <> '货主名称'), 'ALTER TABLE ll_order MODIFY COLUMN customer_name VARCHAR(255) 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='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓 ID'), 'ALTER TABLE ll_order MODIFY COLUMN overseas_warehouse_id VARCHAR(128) 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='overseas_warehouse_name' AND COALESCE(column_comment,'') <> '海外仓名称'), 'ALTER TABLE ll_order MODIFY COLUMN overseas_warehouse_name VARCHAR(255) 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='transfer_warehouse_id' AND COALESCE(column_comment,'') <> '转入目的仓 ID'), 'ALTER TABLE ll_order MODIFY COLUMN transfer_warehouse_id VARCHAR(128) 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='transfer_warehouse_code' AND COALESCE(column_comment,'') <> '转入目的仓编码'), 'ALTER TABLE ll_order MODIFY COLUMN transfer_warehouse_code VARCHAR(128) 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='transfer_warehouse_name' AND COALESCE(column_comment,'') <> '转入目的仓名称'), 'ALTER TABLE ll_order MODIFY COLUMN transfer_warehouse_name VARCHAR(255) 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='fba_warehouse_code' AND COALESCE(column_comment,'') <> 'FBA 仓库编码'), 'ALTER TABLE ll_order MODIFY COLUMN fba_warehouse_code VARCHAR(128) COMMENT ''FBA 仓库编码''', '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='container_no' AND COALESCE(column_comment,'') <> '集装箱号'), 'ALTER TABLE ll_order MODIFY COLUMN container_no VARCHAR(64) 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='container_status' AND COALESCE(column_comment,'') <> '货柜状态'), 'ALTER TABLE ll_order MODIFY COLUMN container_status VARCHAR(32) DEFAULT ''待转柜'' 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='abnormal' AND COALESCE(column_comment,'') <> '是否异常:0否1是'), 'ALTER TABLE ll_order MODIFY COLUMN abnormal TINYINT NOT NULL DEFAULT 0 COMMENT ''是否异常:0否1是''', '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='detained' AND COALESCE(column_comment,'') <> '是否暂扣:0否1是'), 'ALTER TABLE ll_order MODIFY COLUMN detained TINYINT NOT NULL DEFAULT 0 COMMENT ''是否暂扣:0否1是''', '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='pallet_count' AND COALESCE(column_comment,'') <> '托数'), 'ALTER TABLE ll_order MODIFY COLUMN pallet_count 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_order' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_order MODIFY COLUMN package_count 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_order' AND column_name='picked_package_count' AND COALESCE(column_comment,'') <> '累计已拣箱数'), 'ALTER TABLE ll_order MODIFY COLUMN picked_package_count 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_order' AND column_name='shipped_package_count' AND COALESCE(column_comment,'') <> '累计已出库箱数'), 'ALTER TABLE ll_order MODIFY COLUMN shipped_package_count 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_order' AND column_name='volume_cbm' AND COALESCE(column_comment,'') <> '体积(立方米)'), 'ALTER TABLE ll_order MODIFY COLUMN volume_cbm DECIMAL(18,4) 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='product_quantity' AND COALESCE(column_comment,'') <> '货物数量'), 'ALTER TABLE ll_order MODIFY COLUMN product_quantity INT 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='volume_weight_kg' AND COALESCE(column_comment,'') <> '体积重量(千克)'), 'ALTER TABLE ll_order MODIFY COLUMN volume_weight_kg DECIMAL(18,4) 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='estimated_arrival_at' AND COALESCE(column_comment,'') <> '预计到港时间'), 'ALTER TABLE ll_order MODIFY COLUMN estimated_arrival_at DATETIME(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='actual_arrival_at' AND COALESCE(column_comment,'') <> '实际到港时间'), 'ALTER TABLE ll_order MODIFY COLUMN actual_arrival_at DATETIME(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='estimated_warehouse_at' AND COALESCE(column_comment,'') <> '预计到仓时间'), 'ALTER TABLE ll_order MODIFY COLUMN estimated_warehouse_at DATETIME(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='inbound_order_no' AND COALESCE(column_comment,'') <> '入库单号'), 'ALTER TABLE ll_order MODIFY COLUMN inbound_order_no VARCHAR(128) 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='outbound_order_no' AND COALESCE(column_comment,'') <> '出库单号'), 'ALTER TABLE ll_order MODIFY COLUMN outbound_order_no VARCHAR(128) 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='delivery_order_no' AND COALESCE(column_comment,'') <> '派送单号'), 'ALTER TABLE ll_order MODIFY COLUMN delivery_order_no VARCHAR(128) 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='relabel_order_no' AND COALESCE(column_comment,'') <> '换标单号'), 'ALTER TABLE ll_order MODIFY COLUMN relabel_order_no VARCHAR(128) 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='relabel_status' AND COALESCE(column_comment,'') <> '换标状态'), 'ALTER TABLE ll_order MODIFY COLUMN relabel_status VARCHAR(32) 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='pallet_merge_flag' AND COALESCE(column_comment,'') <> '合托标记'), 'ALTER TABLE ll_order MODIFY COLUMN pallet_merge_flag VARCHAR(32) 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='current_status' AND COALESCE(column_comment,'') <> '当前业务状态'), 'ALTER TABLE ll_order MODIFY COLUMN current_status VARCHAR(32) NOT NULL DEFAULT ''PENDING_ASSIGNMENT'' 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='private_remark' AND COALESCE(column_comment,'') <> '备注(仅自己)'), 'ALTER TABLE ll_order MODIFY COLUMN private_remark VARCHAR(1000) 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='customer_remark' AND COALESCE(column_comment,'') <> '备注(客户)'), 'ALTER TABLE ll_order MODIFY COLUMN customer_remark VARCHAR(1000) 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='warehouse_remark' AND COALESCE(column_comment,'') <> '备注(仓库)'), 'ALTER TABLE ll_order MODIFY COLUMN warehouse_remark VARCHAR(1000) 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_event_id' AND COALESCE(column_comment,'') <> '来源事件 ID'), 'ALTER TABLE ll_order MODIFY COLUMN source_event_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_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_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')); +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_payload_hash' AND COALESCE(column_comment,'') <> '来源事件负载摘要'), 'ALTER TABLE ll_order MODIFY COLUMN source_payload_hash VARCHAR(71) 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_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_order 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.tables WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND COALESCE(table_comment,'') <> '尾程订单实装商品冻结明细'), 'ALTER TABLE ll_order_detail 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_detail' AND column_name='id' AND COALESCE(column_comment,'') <> '订单明细主键'), 'ALTER TABLE ll_order_detail MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT 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_detail' AND column_name='order_id' AND COALESCE(column_comment,'') <> '尾程订单 ID'), 'ALTER TABLE ll_order_detail MODIFY COLUMN order_id BIGINT 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_detail' AND column_name='source_inbound_detail_id' AND COALESCE(column_comment,'') <> '来源入仓明细 ID'), 'ALTER TABLE ll_order_detail 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_order_detail' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU 编码'), 'ALTER TABLE ll_order_detail MODIFY COLUMN sku_code VARCHAR(128) 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_order_detail' AND column_name='product_name_zh' AND COALESCE(column_comment,'') <> '中文品名'), 'ALTER TABLE ll_order_detail MODIFY COLUMN product_name_zh VARCHAR(255) 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_detail' AND column_name='product_name_en' AND COALESCE(column_comment,'') <> '英文品名'), 'ALTER TABLE ll_order_detail MODIFY COLUMN product_name_en VARCHAR(255) 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_detail' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_order_detail MODIFY COLUMN package_count 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_order_detail' AND column_name='product_quantity' AND COALESCE(column_comment,'') <> '数量'), 'ALTER TABLE ll_order_detail MODIFY COLUMN product_quantity INT 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_detail' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '托数'), 'ALTER TABLE ll_order_detail MODIFY COLUMN pallet_count INT 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_detail' AND column_name='gross_weight_kg' AND COALESCE(column_comment,'') <> '毛重(千克)'), 'ALTER TABLE ll_order_detail MODIFY COLUMN gross_weight_kg DECIMAL(18,4) 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_detail' AND column_name='net_weight_kg' AND COALESCE(column_comment,'') <> '净重(千克)'), 'ALTER TABLE ll_order_detail MODIFY COLUMN net_weight_kg DECIMAL(18,4) 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_detail' AND column_name='dimensional_weight_kg' AND COALESCE(column_comment,'') <> '体积重量(千克)'), 'ALTER TABLE ll_order_detail MODIFY COLUMN dimensional_weight_kg DECIMAL(18,4) 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_detail' AND column_name='volume_cbm' AND COALESCE(column_comment,'') <> '体积(立方米)'), 'ALTER TABLE ll_order_detail MODIFY COLUMN volume_cbm DECIMAL(18,4) 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_detail' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓 ID'), 'ALTER TABLE ll_order_detail MODIFY COLUMN destination_warehouse_id VARCHAR(128) 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_detail' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码'), 'ALTER TABLE ll_order_detail MODIFY COLUMN destination_warehouse_code VARCHAR(128) 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_detail' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称'), 'ALTER TABLE ll_order_detail MODIFY COLUMN destination_warehouse_name VARCHAR(255) 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_detail' AND column_name='fba_tracking_no' AND COALESCE(column_comment,'') <> 'FBA跟踪号'), 'ALTER TABLE ll_order_detail MODIFY COLUMN fba_tracking_no VARCHAR(255) COMMENT ''FBA跟踪号''', '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_detail' AND column_name='shipment_no' AND COALESCE(column_comment,'') <> '货件编号'), 'ALTER TABLE ll_order_detail MODIFY COLUMN shipment_no VARCHAR(255) 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_detail' AND column_name='shipping_mark' AND COALESCE(column_comment,'') <> '唛头'), 'ALTER TABLE ll_order_detail MODIFY COLUMN shipping_mark VARCHAR(255) 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.tables WHERE table_schema=DATABASE() AND table_name='ll_loading_order_status_history' AND COALESCE(table_comment,'') <> '尾程订单状态变更历史'), 'ALTER TABLE ll_loading_order_status_history 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_loading_order_status_history' AND column_name='id' AND COALESCE(column_comment,'') <> '状态历史主键'), 'ALTER TABLE ll_loading_order_status_history MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT 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_loading_order_status_history' AND column_name='order_id' AND COALESCE(column_comment,'') <> '尾程订单 ID'), 'ALTER TABLE ll_loading_order_status_history MODIFY COLUMN order_id BIGINT 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_loading_order_status_history' AND column_name='from_status' AND COALESCE(column_comment,'') <> '变更前状态'), 'ALTER TABLE ll_loading_order_status_history MODIFY COLUMN from_status VARCHAR(32) 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_loading_order_status_history' AND column_name='to_status' AND COALESCE(column_comment,'') <> '变更后状态'), 'ALTER TABLE ll_loading_order_status_history MODIFY COLUMN to_status VARCHAR(32) 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_loading_order_status_history' AND column_name='event_id' AND COALESCE(column_comment,'') <> '触发事件 ID'), 'ALTER TABLE ll_loading_order_status_history MODIFY COLUMN event_id VARCHAR(128) 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_loading_order_status_history' AND column_name='remark' AND COALESCE(column_comment,'') <> '状态变更备注'), 'ALTER TABLE ll_loading_order_status_history MODIFY COLUMN remark VARCHAR(500) 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_loading_order_status_history' AND column_name='created_at' AND COALESCE(column_comment,'') <> '记录时间'), 'ALTER TABLE ll_loading_order_status_history MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_event_conflict' AND COALESCE(table_comment,'') <> '事件冲突记录'), 'ALTER TABLE ll_event_conflict 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_event_conflict' AND column_name='id' AND COALESCE(column_comment,'') <> '冲突记录主键'), 'ALTER TABLE ll_event_conflict 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_event_conflict' AND column_name='event_id' AND COALESCE(column_comment,'') <> '冲突事件 ID'), 'ALTER TABLE ll_event_conflict MODIFY COLUMN event_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_event_conflict' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户 ID'), 'ALTER TABLE ll_event_conflict MODIFY COLUMN customer_id BIGINT 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_event_conflict' AND column_name='conflict_type' AND COALESCE(column_comment,'') <> '冲突类型'), 'ALTER TABLE ll_event_conflict MODIFY COLUMN conflict_type VARCHAR(64) 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_event_conflict' AND column_name='conflict_message' AND COALESCE(column_comment,'') <> '冲突说明'), 'ALTER TABLE ll_event_conflict MODIFY COLUMN conflict_message VARCHAR(512) 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_event_conflict' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_event_conflict MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 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.tables WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND COALESCE(table_comment,'') <> '通用事件接收幂等箱'), 'ALTER TABLE ll_event_inbox 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_event_inbox' AND column_name='id' AND COALESCE(column_comment,'') <> '事件接收记录主键'), 'ALTER TABLE ll_event_inbox 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_event_inbox' AND column_name='event_id' AND COALESCE(column_comment,'') <> '事件全局唯一 ID'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN event_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_event_inbox' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户 ID'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN customer_id BIGINT 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_event_inbox' AND column_name='event_type' AND COALESCE(column_comment,'') <> '事件类型'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN event_type 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_event_inbox' AND column_name='aggregate_type' AND COALESCE(column_comment,'') <> '聚合类型'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN aggregate_type VARCHAR(64) 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_event_inbox' AND column_name='aggregate_id' AND COALESCE(column_comment,'') <> '聚合 ID'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN aggregate_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_event_inbox' AND column_name='aggregate_version' AND COALESCE(column_comment,'') <> '聚合版本'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN aggregate_version 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_event_inbox' AND column_name='payload_hash' AND COALESCE(column_comment,'') <> '负载摘要'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN payload_hash VARCHAR(71) 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_event_inbox' AND column_name='status' AND COALESCE(column_comment,'') <> '消费状态'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN status VARCHAR(32) 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_event_inbox' AND column_name='error_message' AND COALESCE(column_comment,'') <> '错误信息'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN error_message VARCHAR(512) 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_event_inbox' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 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_event_inbox' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP 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.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND COALESCE(table_comment,'') <> '尾程入仓单快照'), 'ALTER TABLE ll_inbound_order 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_order' AND column_name='id' AND COALESCE(column_comment,'') <> '入仓单主键'), 'ALTER TABLE ll_inbound_order 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_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户 ID'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN customer_id BIGINT 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_order' AND column_name='source_region' AND COALESCE(column_comment,'') <> '来源区域'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN source_region VARCHAR(32) 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_order' AND column_name='source_inbound_id' AND COALESCE(column_comment,'') <> '来源入仓分片 ID'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN source_inbound_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_order' AND column_name='root_inbound_id' AND COALESCE(column_comment,'') <> '根入仓单 ID'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN root_inbound_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_order' AND column_name='source_order_id' AND COALESCE(column_comment,'') <> '来源订单 ID'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN source_order_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_order' AND column_name='source_order_no' AND COALESCE(column_comment,'') <> '来源订单号'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN source_order_no 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_order' AND column_name='root_order_id' AND COALESCE(column_comment,'') <> '根订单 ID'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN root_order_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_order' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓码'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN inbound_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_order' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓 ID'), 'ALTER TABLE ll_inbound_order 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_order' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码'), 'ALTER TABLE ll_inbound_order 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_order' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称'), 'ALTER TABLE ll_inbound_order 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_order' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN package_count 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_order' AND column_name='product_quantity' AND COALESCE(column_comment,'') <> '数量'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN product_quantity INT 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_order' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '托数'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN pallet_count INT 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_order' AND column_name='gross_weight_kg' AND COALESCE(column_comment,'') <> '毛重(千克)'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN gross_weight_kg DECIMAL(18,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_order' AND column_name='net_weight_kg' AND COALESCE(column_comment,'') <> '净重(千克)'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN net_weight_kg DECIMAL(18,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_order' AND column_name='volume_cbm' AND COALESCE(column_comment,'') <> '体积(立方米)'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN volume_cbm DECIMAL(18,6) 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_order' AND column_name='source_event_id' AND COALESCE(column_comment,'') <> '来源事件 ID'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN source_event_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_order' AND column_name='source_loading_plan_id' AND COALESCE(column_comment,'') <> '装柜计划 ID'), 'ALTER TABLE ll_inbound_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_inbound_order' AND column_name='source_loading_plan_version' AND COALESCE(column_comment,'') <> '装柜计划版本'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN source_loading_plan_version 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_order' AND column_name='handover_version' AND COALESCE(column_comment,'') <> '交接版本'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN handover_version 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_order' AND column_name='payload_hash' AND COALESCE(column_comment,'') <> '负载摘要'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN payload_hash VARCHAR(71) 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_order' AND column_name='status' AND COALESCE(column_comment,'') <> '入仓单状态'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN status VARCHAR(32) 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_order' AND column_name='exception_flag' AND COALESCE(column_comment,'') <> '异常标记'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN exception_flag VARCHAR(32) NOT NULL DEFAULT ''NONE'' 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_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 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_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP 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.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND COALESCE(table_comment,'') <> '尾程入仓商品明细快照'), 'ALTER TABLE ll_inbound_order_item 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_order_item' AND column_name='id' AND COALESCE(column_comment,'') <> '入仓明细主键'), 'ALTER TABLE ll_inbound_order_item 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_order_item' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户 ID'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN customer_id BIGINT 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_order_item' AND column_name='order_id' AND COALESCE(column_comment,'') <> '入仓单 ID'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN order_id BIGINT 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_order_item' AND column_name='source_inbound_detail_id' AND COALESCE(column_comment,'') <> '来源入仓明细 ID'), 'ALTER TABLE ll_inbound_order_item 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_order_item' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN package_count INT 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_order_item' AND column_name='product_quantity' AND COALESCE(column_comment,'') <> '数量'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN product_quantity INT 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_order_item' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '托数'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN pallet_count INT 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_order_item' AND column_name='gross_weight_kg' AND COALESCE(column_comment,'') <> '毛重(千克)'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN gross_weight_kg DECIMAL(18,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_order_item' AND column_name='net_weight_kg' AND COALESCE(column_comment,'') <> '净重(千克)'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN net_weight_kg DECIMAL(18,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_order_item' AND column_name='dimensional_weight_kg' AND COALESCE(column_comment,'') <> '体积重量(千克)'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN dimensional_weight_kg DECIMAL(18,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_order_item' AND column_name='volume_cbm' AND COALESCE(column_comment,'') <> '体积(立方米)'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN volume_cbm DECIMAL(18,6) 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_order_item' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓 ID'), 'ALTER TABLE ll_inbound_order_item 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_order_item' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码'), 'ALTER TABLE ll_inbound_order_item 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_order_item' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称'), 'ALTER TABLE ll_inbound_order_item 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_order_item' AND column_name='fba_tracking_no' AND COALESCE(column_comment,'') <> 'FBA跟踪号'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN fba_tracking_no VARCHAR(128) NULL COMMENT ''FBA跟踪号''', '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_order_item' AND column_name='shipment_no' AND COALESCE(column_comment,'') <> '货件编号'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN shipment_no 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_order_item' AND column_name='shipping_mark' AND COALESCE(column_comment,'') <> '唛头'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN shipping_mark 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_order_item' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU 编码'), 'ALTER TABLE ll_inbound_order_item 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_order_item' AND column_name='product_name_zh' AND COALESCE(column_comment,'') <> '中文品名'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN product_name_zh 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_order_item' AND column_name='product_name_en' AND COALESCE(column_comment,'') <> '英文品名'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN product_name_en 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_order_item' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 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.tables WHERE table_schema=DATABASE() AND table_name='ll_order_status_history' AND COALESCE(table_comment,'') <> '尾程订单状态历史'), 'ALTER TABLE ll_order_status_history 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_status_history' AND column_name='id' AND COALESCE(column_comment,'') <> '订单状态历史主键'), 'ALTER TABLE ll_order_status_history 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_order_status_history' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户 ID'), 'ALTER TABLE ll_order_status_history MODIFY COLUMN customer_id BIGINT 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_status_history' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单 ID'), 'ALTER TABLE ll_order_status_history MODIFY COLUMN order_id BIGINT 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_status_history' AND column_name='from_status' AND COALESCE(column_comment,'') <> '变更前状态'), 'ALTER TABLE ll_order_status_history MODIFY COLUMN from_status VARCHAR(32) 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_order_status_history' AND column_name='to_status' AND COALESCE(column_comment,'') <> '变更后状态'), 'ALTER TABLE ll_order_status_history MODIFY COLUMN to_status VARCHAR(32) 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_order_status_history' AND column_name='reason' AND COALESCE(column_comment,'') <> '变更原因'), 'ALTER TABLE ll_order_status_history MODIFY COLUMN reason 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_order_status_history' AND column_name='created_at' AND COALESCE(column_comment,'') <> '记录时间'), 'ALTER TABLE ll_order_status_history MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 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.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND COALESCE(table_comment,'') <> '尾程入库计划表:每个租户的每个集装箱生成一张入库单'), 'ALTER TABLE ll_inbound_plan 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' AND column_name='id' AND COALESCE(column_comment,'') <> '入库计划ID(雪花ID)'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN id BIGINT NOT NULL COMMENT ''入库计划ID(雪花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' AND column_name='plan_no' AND COALESCE(column_comment,'') <> '入库单号'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN plan_no VARCHAR(32) 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' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户/客户账号标识'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN customer_id 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' AND column_name='container_no' AND COALESCE(column_comment,'') <> '集装箱号'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN container_no VARCHAR(64) 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' AND column_name='status' AND COALESCE(column_comment,'') <> '入库计划状态'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PLANNED'' 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' AND column_name='line_count' AND COALESCE(column_comment,'') <> '入仓码明细数量'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN line_count 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' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '系统自动分配托盘数量'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN pallet_count 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' AND column_name='total_volume_cbm' AND COALESCE(column_comment,'') <> '集装箱总体积(立方米)'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN total_volume_cbm DECIMAL(18,4) 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' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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_inbound_plan' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inbound_plan 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.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND COALESCE(table_comment,'') <> '尾程入库计划明细表:按入仓码和尾程订单维度记录'), 'ALTER TABLE ll_inbound_plan_line 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_line' AND column_name='id' AND COALESCE(column_comment,'') <> '入库计划明细ID(雪花ID)'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN id BIGINT NOT NULL COMMENT ''入库计划明细ID(雪花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_line' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入库计划ID'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN plan_id BIGINT 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_line' AND column_name='order_id' AND COALESCE(column_comment,'') <> '尾程订单ID'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN order_id BIGINT 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_line' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '头程入仓码快照'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN inbound_code 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_line' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓ID快照'), 'ALTER TABLE ll_inbound_plan_line 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_line' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_inbound_plan_line 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_line' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称快照'), 'ALTER TABLE ll_inbound_plan_line 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_line' AND column_name='volume_cbm' AND COALESCE(column_comment,'') <> '入仓码体积(立方米)'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN volume_cbm DECIMAL(18,4) 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_line' AND column_name='planned_pallet_count' AND COALESCE(column_comment,'') <> '该明细分配的托盘数量'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN planned_pallet_count 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.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND COALESCE(table_comment,'') <> '尾程入库计划托盘表:按目的仓隔离合托'), 'ALTER TABLE ll_inbound_plan_pallet 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_pallet' AND column_name='id' AND COALESCE(column_comment,'') <> '计划托盘ID(雪花ID)'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN id BIGINT NOT NULL COMMENT ''计划托盘ID(雪花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_pallet' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入库计划ID'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN plan_id BIGINT 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_pallet' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '计划托盘编号'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN pallet_no VARCHAR(64) 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_pallet' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓ID快照'), 'ALTER TABLE ll_inbound_plan_pallet 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_pallet' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_inbound_plan_pallet 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_pallet' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称快照'), 'ALTER TABLE ll_inbound_plan_pallet 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_pallet' AND column_name='capacity_cbm' AND COALESCE(column_comment,'') <> '托盘容量,固定为2立方米'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT ''托盘容量,固定为2立方米''', '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_pallet' AND column_name='allocated_volume_cbm' AND COALESCE(column_comment,'') <> '已分配体积(立方米)'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN allocated_volume_cbm DECIMAL(18,4) 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_pallet' AND column_name='sequence_no' AND COALESCE(column_comment,'') <> '计划内托盘序号'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN sequence_no INT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND COALESCE(table_comment,'') <> '尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'), 'ALTER TABLE ll_inbound_plan_pallet_allocation 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_pallet_allocation' AND column_name='id' AND COALESCE(column_comment,'') <> '托盘分配记录ID(雪花ID)'), 'ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN id BIGINT NOT NULL COMMENT ''托盘分配记录ID(雪花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_pallet_allocation' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入库计划ID'), 'ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN plan_id BIGINT 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_pallet_allocation' AND column_name='line_id' AND COALESCE(column_comment,'') <> '入库计划明细ID'), 'ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN line_id BIGINT 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_pallet_allocation' AND column_name='pallet_id' AND COALESCE(column_comment,'') <> '计划托盘ID'), 'ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN pallet_id BIGINT 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_pallet_allocation' AND column_name='allocated_volume_cbm' AND COALESCE(column_comment,'') <> '该明细分配到托盘的体积(立方米)'), 'ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN allocated_volume_cbm DECIMAL(18,4) 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.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND COALESCE(table_comment,'') <> '入仓PDA作业任务'), 'ALTER TABLE ll_inbound_pda_task COMMENT = ''入仓PDA作业任务''', '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_pda_task' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN id BIGINT 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_pda_task' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inbound_pda_task 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_pda_task' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入仓计划ID'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN plan_id BIGINT 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_pda_task' AND column_name='task_no' AND COALESCE(column_comment,'') <> 'PDA任务号'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN task_no VARCHAR(64) NOT NULL COMMENT ''PDA任务号''', '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_pda_task' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' 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_pda_task' AND column_name='operator_id' AND COALESCE(column_comment,'') <> '操作人ID'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN operator_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_pda_task' AND column_name='operator_name' AND COALESCE(column_comment,'') <> '操作人姓名'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN operator_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_pda_task' AND column_name='pushed_at' AND COALESCE(column_comment,'') <> '任务下发时间'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN pushed_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_pda_task' AND column_name='started_at' AND COALESCE(column_comment,'') <> '开始时间'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN started_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_pda_task' AND column_name='completed_at' AND COALESCE(column_comment,'') <> '完成时间'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN completed_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_pda_task' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_inbound_pda_task 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_pda_task' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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_inbound_pda_task' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inbound_pda_task 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.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND COALESCE(table_comment,'') <> '入仓PDA操作幂等记录'), 'ALTER TABLE ll_inbound_pda_operation COMMENT = ''入仓PDA操作幂等记录''', '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_pda_operation' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN id BIGINT 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_pda_operation' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inbound_pda_operation 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_pda_operation' AND column_name='task_id' AND COALESCE(column_comment,'') <> 'PDA任务ID'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN task_id BIGINT NOT 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_pda_operation' AND column_name='allocation_id' AND COALESCE(column_comment,'') <> '分配记录ID'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN allocation_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_pda_operation' AND column_name='action' AND COALESCE(column_comment,'') <> '操作动作'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN action VARCHAR(32) 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_pda_operation' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN idempotency_key VARCHAR(255) 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_pda_operation' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN request_hash 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_pda_operation' AND column_name='request_json' AND COALESCE(column_comment,'') <> '请求内容JSON'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN request_json LONGTEXT NOT NULL COMMENT ''请求内容JSON''', '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_pda_operation' AND column_name='result_json' AND COALESCE(column_comment,'') <> '处理结果JSON'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN result_json LONGTEXT NOT NULL COMMENT ''处理结果JSON''', '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_pda_operation' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND COALESCE(table_comment,'') <> '入仓托盘合托记录'), 'ALTER TABLE ll_inbound_pallet_merge 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_pallet_merge' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN id BIGINT 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_pallet_merge' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inbound_pallet_merge 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_pallet_merge' AND column_name='task_id' AND COALESCE(column_comment,'') <> 'PDA任务ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN task_id BIGINT NOT 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_pallet_merge' AND column_name='merge_group_no' AND COALESCE(column_comment,'') <> '合托分组号'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN merge_group_no VARCHAR(64) 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_pallet_merge' AND column_name='source_pallet_id' AND COALESCE(column_comment,'') <> '来源托盘ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN source_pallet_id BIGINT 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_pallet_merge' AND column_name='target_pallet_id' AND COALESCE(column_comment,'') <> '目标托盘ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN target_pallet_id BIGINT 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_pallet_merge' AND column_name='source_allocation_id' AND COALESCE(column_comment,'') <> '来源分配记录ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN source_allocation_id BIGINT 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_pallet_merge' AND column_name='target_allocation_id' AND COALESCE(column_comment,'') <> '目标分配记录ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN target_allocation_id BIGINT 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_pallet_merge' AND column_name='source_line_id' AND COALESCE(column_comment,'') <> '来源计划明细ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN source_line_id BIGINT 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_pallet_merge' AND column_name='target_line_id' AND COALESCE(column_comment,'') <> '目标计划明细ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN target_line_id BIGINT 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_pallet_merge' AND column_name='source_order_id' AND COALESCE(column_comment,'') <> '来源订单ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN source_order_id BIGINT 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_pallet_merge' AND column_name='target_order_id' AND COALESCE(column_comment,'') <> '目标订单ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN target_order_id BIGINT 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_pallet_merge' AND column_name='moved_package_count' AND COALESCE(column_comment,'') <> '移动箱数'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN moved_package_count INT 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_pallet_merge' AND column_name='moved_volume_cbm' AND COALESCE(column_comment,'') <> '移动体积(立方米)'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN moved_volume_cbm DECIMAL(18,4) 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_pallet_merge' AND column_name='source_pallet_scrapped' AND COALESCE(column_comment,'') <> '来源托盘是否已报废'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN source_pallet_scrapped TINYINT 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_pallet_merge' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN status VARCHAR(32) 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_pallet_merge' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND COALESCE(table_comment,'') <> 'FBA入仓预约单'), 'ALTER TABLE ll_inbound_fba_appointment COMMENT = ''FBA入仓预约单''', '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_fba_appointment' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN id BIGINT 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_fba_appointment' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inbound_fba_appointment 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_fba_appointment' AND column_name='appointment_no' AND COALESCE(column_comment,'') <> '预约单号'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN appointment_no VARCHAR(64) 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_fba_appointment' AND column_name='appointment_at' AND COALESCE(column_comment,'') <> '预约时间'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN appointment_at DATETIME(3) 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_fba_appointment' AND column_name='appointment_status' AND COALESCE(column_comment,'') <> '预约状态'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN appointment_status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' 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_fba_appointment' AND column_name='appointment_remark' AND COALESCE(column_comment,'') <> '预约备注'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN appointment_remark VARCHAR(1000) 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_fba_appointment' AND column_name='fba_warehouse_code' AND COALESCE(column_comment,'') <> 'FBA仓库编码'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN fba_warehouse_code VARCHAR(128) NOT NULL COMMENT ''FBA仓库编码''', '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_fba_appointment' AND column_name='selected_count' AND COALESCE(column_comment,'') <> '已选明细数量'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN selected_count 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_fba_appointment' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN created_by 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_fba_appointment' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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_inbound_fba_appointment' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inbound_fba_appointment 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.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment_item' AND COALESCE(table_comment,'') <> 'FBA入仓预约明细'), 'ALTER TABLE ll_inbound_fba_appointment_item COMMENT = ''FBA入仓预约明细''', '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_fba_appointment_item' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN id BIGINT 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_fba_appointment_item' AND column_name='appointment_id' AND COALESCE(column_comment,'') <> '预约单ID'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN appointment_id BIGINT 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_fba_appointment_item' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入仓计划ID'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN plan_id BIGINT 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_fba_appointment_item' AND column_name='line_id' AND COALESCE(column_comment,'') <> '入仓计划明细ID'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN line_id BIGINT 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_fba_appointment_item' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN order_id BIGINT 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_fba_appointment_item' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN inbound_code 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_fba_appointment_item' AND column_name='fba_warehouse_code' AND COALESCE(column_comment,'') <> 'FBA仓库编码'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN fba_warehouse_code VARCHAR(128) NOT NULL COMMENT ''FBA仓库编码''', '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_fba_appointment_item' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND COALESCE(table_comment,'') <> '入仓单维度实时库存余额'), 'ALTER TABLE ll_inventory_balance 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_inventory_balance' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN id BIGINT 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_inventory_balance' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inventory_balance 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_inventory_balance' AND column_name='customer_name' AND COALESCE(column_comment,'') <> '客户名称快照'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN customer_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_inventory_balance' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN overseas_warehouse_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_inventory_balance' AND column_name='overseas_warehouse_code' AND COALESCE(column_comment,'') <> '海外仓编码快照'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN overseas_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_inventory_balance' AND column_name='overseas_warehouse_name' AND COALESCE(column_comment,'') <> '海外仓名称快照'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN overseas_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_inventory_balance' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓ID'), 'ALTER TABLE ll_inventory_balance 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_inventory_balance' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '''' 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_inventory_balance' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称快照'), 'ALTER TABLE ll_inventory_balance 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_inventory_balance' AND column_name='inbound_order_id' AND COALESCE(column_comment,'') <> '入仓单ID'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_order_id BIGINT 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_inventory_balance' AND column_name='inbound_order_no' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_order_no 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_inventory_balance' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_code 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_inventory_balance' AND column_name='available_packages' AND COALESCE(column_comment,'') <> '可用箱数'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN available_packages BIGINT 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_inventory_balance' AND column_name='hold_packages' AND COALESCE(column_comment,'') <> '冻结箱数'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN hold_packages BIGINT 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_inventory_balance' AND column_name='allocated_packages' AND COALESCE(column_comment,'') <> '已分配箱数'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN allocated_packages BIGINT 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_inventory_balance' AND column_name='picked_packages' AND COALESCE(column_comment,'') <> '已拣箱数'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN picked_packages BIGINT 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_inventory_balance' AND column_name='in_transit_packages' AND COALESCE(column_comment,'') <> '在途箱数'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN in_transit_packages BIGINT 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_inventory_balance' AND column_name='receiving_packages' AND COALESCE(column_comment,'') <> '收货处理中箱数'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN receiving_packages BIGINT 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_inventory_balance' AND column_name='procurement_packages' AND COALESCE(column_comment,'') <> '采购业务占用箱数'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN procurement_packages BIGINT 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_inventory_balance' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN version BIGINT 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_inventory_balance' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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_inventory_balance' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inventory_balance 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.tables WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND COALESCE(table_comment,'') <> '托盘库位批次库存明细'), 'ALTER TABLE ll_inventory_position 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_inventory_position' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN id BIGINT 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_inventory_position' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inventory_position 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_inventory_position' AND column_name='balance_id' AND COALESCE(column_comment,'') <> '库存余额记录ID'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN balance_id BIGINT 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_inventory_position' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN overseas_warehouse_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_inventory_position' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '''' 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_inventory_position' AND column_name='pallet_id' AND COALESCE(column_comment,'') <> '托盘ID'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN pallet_id BIGINT 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_inventory_position' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '托盘号'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN pallet_no VARCHAR(64) 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_inventory_position' AND column_name='batch_no' AND COALESCE(column_comment,'') <> '库存批次号'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN batch_no VARCHAR(128) NOT NULL DEFAULT '''' 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_inventory_position' AND column_name='warehouse_location_id' AND COALESCE(column_comment,'') <> '仓库库位ID'), 'ALTER TABLE ll_inventory_position 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_inventory_position' AND column_name='warehouse_location_code' AND COALESCE(column_comment,'') <> '仓库库位编码'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN warehouse_location_code 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_inventory_position' AND column_name='available_packages' AND COALESCE(column_comment,'') <> '可用箱数'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN available_packages BIGINT 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_inventory_position' AND column_name='hold_packages' AND COALESCE(column_comment,'') <> '冻结箱数'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN hold_packages BIGINT 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_inventory_position' AND column_name='allocated_packages' AND COALESCE(column_comment,'') <> '已分配箱数'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN allocated_packages BIGINT 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_inventory_position' AND column_name='picked_packages' AND COALESCE(column_comment,'') <> '已拣箱数'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN picked_packages BIGINT 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_inventory_position' AND column_name='receiving_packages' AND COALESCE(column_comment,'') <> '收货处理中箱数'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN receiving_packages BIGINT 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_inventory_position' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''ACTIVE'' 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_inventory_position' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN version BIGINT 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_inventory_position' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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_inventory_position' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inventory_position 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.tables WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND COALESCE(table_comment,'') <> '不可变库存变动流水'), 'ALTER TABLE ll_inventory_ledger 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_inventory_ledger' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN id BIGINT 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_inventory_ledger' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inventory_ledger 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_inventory_ledger' AND column_name='balance_id' AND COALESCE(column_comment,'') <> '库存余额记录ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN balance_id BIGINT 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_inventory_ledger' AND column_name='position_id' AND COALESCE(column_comment,'') <> '库存仓位记录ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN position_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_inventory_ledger' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN overseas_warehouse_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_inventory_ledger' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '''' 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_inventory_ledger' AND column_name='inbound_order_id' AND COALESCE(column_comment,'') <> '入仓单ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN inbound_order_id BIGINT 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_inventory_ledger' AND column_name='pallet_id' AND COALESCE(column_comment,'') <> '托盘ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN 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_inventory_ledger' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '托盘号'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN pallet_no VARCHAR(64) 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_inventory_ledger' AND column_name='batch_no' AND COALESCE(column_comment,'') <> '库存批次号'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN batch_no 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_inventory_ledger' AND column_name='warehouse_location_code' AND COALESCE(column_comment,'') <> '仓库库位编码'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN warehouse_location_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_inventory_ledger' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码'), 'ALTER TABLE ll_inventory_ledger 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_inventory_ledger' AND column_name='carton_mark' AND COALESCE(column_comment,'') <> '箱唛'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN carton_mark 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_inventory_ledger' AND column_name='carton_packages' AND COALESCE(column_comment,'') <> '箱内箱数'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN carton_packages BIGINT 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_inventory_ledger' AND column_name='business_type' AND COALESCE(column_comment,'') <> '业务类型'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN business_type VARCHAR(32) 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_inventory_ledger' AND column_name='business_order_id' AND COALESCE(column_comment,'') <> '业务单据ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN business_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_inventory_ledger' AND column_name='business_order_no' AND COALESCE(column_comment,'') <> '业务单号'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN business_order_no 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_inventory_ledger' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN inbound_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_inventory_ledger' AND column_name='inbound_order_no' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN inbound_order_no 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_inventory_ledger' AND column_name='outbound_order_no' AND COALESCE(column_comment,'') <> '出库单号'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN outbound_order_no 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_inventory_ledger' AND column_name='source_event_id' AND COALESCE(column_comment,'') <> '来源事件ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN source_event_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_inventory_ledger' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN idempotency_key VARCHAR(255) 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_inventory_ledger' AND column_name='status_from' AND COALESCE(column_comment,'') <> '操作前状态'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN status_from VARCHAR(32) 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_inventory_ledger' AND column_name='status_to' AND COALESCE(column_comment,'') <> '操作后状态'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN status_to VARCHAR(32) 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_inventory_ledger' AND column_name='before_packages' AND COALESCE(column_comment,'') <> '变动前箱数'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN before_packages 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_inventory_ledger' AND column_name='delta_packages' AND COALESCE(column_comment,'') <> '箱数变动量'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN delta_packages 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_inventory_ledger' AND column_name='after_packages' AND COALESCE(column_comment,'') <> '变动后箱数'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN after_packages 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_inventory_ledger' AND column_name='operator_id' AND COALESCE(column_comment,'') <> '操作人ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN operator_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_inventory_ledger' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN remark VARCHAR(1000) 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_inventory_ledger' AND column_name='occurred_at' AND COALESCE(column_comment,'') <> '业务发生时间'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN occurred_at DATETIME(3) 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_inventory_ledger' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND COALESCE(table_comment,'') <> '库存命令幂等记录'), 'ALTER TABLE ll_inventory_operation 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_inventory_operation' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN id BIGINT 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_inventory_operation' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inventory_operation 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_inventory_operation' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN idempotency_key VARCHAR(255) 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_inventory_operation' AND column_name='operation_type' AND COALESCE(column_comment,'') <> '操作类型'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN operation_type VARCHAR(32) 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_inventory_operation' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN request_hash 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_inventory_operation' AND column_name='status' AND COALESCE(column_comment,'') <> '库存命令处理状态'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PROCESSING'' 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_inventory_operation' AND column_name='result_json' AND COALESCE(column_comment,'') <> '处理结果JSON'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN result_json LONGTEXT NULL COMMENT ''处理结果JSON''', '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_inventory_operation' AND column_name='last_error' AND COALESCE(column_comment,'') <> '最后错误信息'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN last_error VARCHAR(2000) 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_inventory_operation' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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_inventory_operation' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inventory_operation 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.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND COALESCE(table_comment,'') <> '入仓PDA库存实收快照'), 'ALTER TABLE ll_inbound_stock_receipt COMMENT = ''入仓PDA库存实收快照''', '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_stock_receipt' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN id BIGINT 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_stock_receipt' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inbound_stock_receipt 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_stock_receipt' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入仓计划ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN plan_id BIGINT 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_stock_receipt' AND column_name='task_id' AND COALESCE(column_comment,'') <> 'PDA任务ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN task_id BIGINT NOT 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_stock_receipt' AND column_name='allocation_id' AND COALESCE(column_comment,'') <> '分配记录ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN allocation_id BIGINT 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_stock_receipt' AND column_name='line_id' AND COALESCE(column_comment,'') <> '入仓计划明细ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN line_id BIGINT 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_stock_receipt' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN order_id BIGINT 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_stock_receipt' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN inbound_code 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_stock_receipt' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '仓库实物托盘ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN warehouse_pallet_id BIGINT 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_stock_receipt' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '托盘号'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN pallet_no VARCHAR(64) 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_stock_receipt' AND column_name='warehouse_location_id' AND COALESCE(column_comment,'') <> '仓库库位ID'), 'ALTER TABLE ll_inbound_stock_receipt 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_stock_receipt' AND column_name='warehouse_location_code' AND COALESCE(column_comment,'') <> '仓库库位编码'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN warehouse_location_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_stock_receipt' AND column_name='batch_no' AND COALESCE(column_comment,'') <> '库存批次号'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN batch_no 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_stock_receipt' AND column_name='received_packages' AND COALESCE(column_comment,'') <> '实收箱数'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN received_packages 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_stock_receipt' AND column_name='inventory_balance_id' AND COALESCE(column_comment,'') <> '库存余额记录ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN inventory_balance_id BIGINT 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_stock_receipt' AND column_name='inventory_position_id' AND COALESCE(column_comment,'') <> '库存仓位记录ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN inventory_position_id BIGINT 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_stock_receipt' AND column_name='inventory_operation_key' AND COALESCE(column_comment,'') <> '库存操作幂等键'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN inventory_operation_key VARCHAR(255) 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_stock_receipt' AND column_name='received_at' AND COALESCE(column_comment,'') <> '实收时间'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN received_at DATETIME(3) 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_stock_receipt' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND COALESCE(table_comment,'') <> '尾程派车单'), 'ALTER TABLE ll_dispatch_order 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_dispatch_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN id BIGINT 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_dispatch_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dispatch_order 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_dispatch_order' AND column_name='dispatch_no' AND COALESCE(column_comment,'') <> '派车单号'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN dispatch_no VARCHAR(64) 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_dispatch_order' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN overseas_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_dispatch_order' AND column_name='overseas_warehouse_name' AND COALESCE(column_comment,'') <> '海外仓名称快照'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN overseas_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_dispatch_order' AND column_name='vehicle_plate' AND COALESCE(column_comment,'') <> '车牌号'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN vehicle_plate VARCHAR(64) 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_dispatch_order' AND column_name='driver_name' AND COALESCE(column_comment,'') <> '司机姓名'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN driver_name 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_dispatch_order' AND column_name='driver_phone' AND COALESCE(column_comment,'') <> '司机电话'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN driver_phone VARCHAR(64) 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_dispatch_order' AND column_name='carrier_name' AND COALESCE(column_comment,'') <> '承运商名称'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN carrier_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_dispatch_order' AND column_name='route' AND COALESCE(column_comment,'') <> '运输路线'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN route 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_dispatch_order' AND column_name='depart_at' AND COALESCE(column_comment,'') <> '发车时间'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN depart_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_dispatch_order' AND column_name='fba_appointment_no' AND COALESCE(column_comment,'') <> 'FBA预约号'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN fba_appointment_no VARCHAR(64) NULL COMMENT ''FBA预约号''', '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_dispatch_order' AND column_name='fba_appointment_status' AND COALESCE(column_comment,'') <> 'FBA预约状态'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN fba_appointment_status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''FBA预约状态''', '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_dispatch_order' AND column_name='vehicle_appointment_status' AND COALESCE(column_comment,'') <> '车辆预约状态'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN vehicle_appointment_status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' 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_dispatch_order' AND column_name='estimated_arrival_at' AND COALESCE(column_comment,'') <> '预计到达时间'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN estimated_arrival_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_dispatch_order' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_DEPARTURE'' 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_dispatch_order' AND column_name='item_count' AND COALESCE(column_comment,'') <> '明细数量'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN item_count 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_dispatch_order' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '托盘数'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN pallet_count 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_dispatch_order' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN package_count 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_dispatch_order' AND column_name='picked_package_count' AND COALESCE(column_comment,'') <> 'PDA累计已拣箱数'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''PDA累计已拣箱数''', '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_dispatch_order' AND column_name='shipped_package_count' AND COALESCE(column_comment,'') <> '累计已出库箱数'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN shipped_package_count 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_dispatch_order' AND column_name='partial_completion_reason' AND COALESCE(column_comment,'') <> '部分完成原因'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN partial_completion_reason VARCHAR(1000) 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_dispatch_order' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN created_by 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_dispatch_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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_dispatch_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_dispatch_order 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.tables WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND COALESCE(table_comment,'') <> '派车货物冻结明细'), 'ALTER TABLE ll_dispatch_item 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_dispatch_item' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN id BIGINT 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_dispatch_item' AND column_name='dispatch_order_id' AND COALESCE(column_comment,'') <> '派车单ID'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN dispatch_order_id BIGINT 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_dispatch_item' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dispatch_item 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_dispatch_item' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入仓计划ID'), 'ALTER TABLE ll_dispatch_item 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_dispatch_item' AND column_name='plan_pallet_id' AND COALESCE(column_comment,'') <> '计划托盘ID'), 'ALTER TABLE ll_dispatch_item 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_dispatch_item' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '仓库实物托盘ID'), 'ALTER TABLE ll_dispatch_item 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_dispatch_item' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_dispatch_item 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_dispatch_item' AND column_name='order_no' AND COALESCE(column_comment,'') <> '订单号'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN order_no VARCHAR(64) 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_dispatch_item' AND column_name='order_refs' AND COALESCE(column_comment,'') <> '关联订单信息'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN order_refs VARCHAR(2000) 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_dispatch_item' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN inbound_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_dispatch_item' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '托盘号'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN pallet_no 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_dispatch_item' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN overseas_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_dispatch_item' AND column_name='overseas_warehouse_name' AND COALESCE(column_comment,'') <> '海外仓名称快照'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN overseas_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_dispatch_item' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓ID'), 'ALTER TABLE ll_dispatch_item 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_dispatch_item' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_dispatch_item 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_dispatch_item' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称快照'), 'ALTER TABLE ll_dispatch_item 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_dispatch_item' AND column_name='fba_warehouse_code' AND COALESCE(column_comment,'') <> 'FBA仓库编码'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码''', '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_dispatch_item' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN package_count 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_dispatch_item' AND column_name='volume_cbm' AND COALESCE(column_comment,'') <> '体积(立方米)'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN volume_cbm DECIMAL(18,4) 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_dispatch_item' AND column_name='source_stage' AND COALESCE(column_comment,'') <> '数据来源阶段'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN source_stage VARCHAR(32) 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_dispatch_item' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND COALESCE(table_comment,'') <> '派车托盘排他锁'), 'ALTER TABLE ll_dispatch_pallet_lock 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_dispatch_pallet_lock' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN id BIGINT 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_dispatch_pallet_lock' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dispatch_pallet_lock 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_dispatch_pallet_lock' AND column_name='source_pallet_key' AND COALESCE(column_comment,'') <> '来源托盘业务键'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN source_pallet_key VARCHAR(160) 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_dispatch_pallet_lock' AND column_name='active_key' AND COALESCE(column_comment,'') <> '生效中的资源排他键'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN active_key VARCHAR(160) 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_dispatch_pallet_lock' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入仓计划ID'), 'ALTER TABLE ll_dispatch_pallet_lock 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_dispatch_pallet_lock' AND column_name='plan_pallet_id' AND COALESCE(column_comment,'') <> '计划托盘ID'), 'ALTER TABLE ll_dispatch_pallet_lock 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_dispatch_pallet_lock' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '仓库实物托盘ID'), 'ALTER TABLE ll_dispatch_pallet_lock 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_dispatch_pallet_lock' AND column_name='dispatch_order_id' AND COALESCE(column_comment,'') <> '派车单ID'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN dispatch_order_id BIGINT 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_dispatch_pallet_lock' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''ACTIVE'' 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_dispatch_pallet_lock' AND column_name='locked_at' AND COALESCE(column_comment,'') <> '锁定时间'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN locked_at DATETIME(3) 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_dispatch_pallet_lock' AND column_name='released_at' AND COALESCE(column_comment,'') <> '释放时间'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN released_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_dispatch_pallet_lock' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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_dispatch_pallet_lock' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_dispatch_pallet_lock 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.tables WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND COALESCE(table_comment,'') <> '派车PDA拣货任务'), 'ALTER TABLE ll_dispatch_pda_task COMMENT = ''派车PDA拣货任务''', '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_dispatch_pda_task' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN id BIGINT 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_dispatch_pda_task' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dispatch_pda_task 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_dispatch_pda_task' AND column_name='dispatch_order_id' AND COALESCE(column_comment,'') <> '派车单ID'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN dispatch_order_id BIGINT 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_dispatch_pda_task' AND column_name='task_no' AND COALESCE(column_comment,'') <> 'PDA任务号'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN task_no VARCHAR(64) NOT NULL COMMENT ''PDA任务号''', '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_dispatch_pda_task' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN status VARCHAR(32) 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_dispatch_pda_task' AND column_name='operator_id' AND COALESCE(column_comment,'') <> '操作人ID'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN operator_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_dispatch_pda_task' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '仓库ID'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN 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_dispatch_pda_task' AND column_name='operator_name' AND COALESCE(column_comment,'') <> '操作人姓名'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN operator_name 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_dispatch_pda_task' AND column_name='pushed_at' AND COALESCE(column_comment,'') <> '任务下发时间'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN pushed_at DATETIME(3) 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_dispatch_pda_task' AND column_name='completed_at' AND COALESCE(column_comment,'') <> '完成时间'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN completed_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_dispatch_pda_task' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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_dispatch_pda_task' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_dispatch_pda_task 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.tables WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND COALESCE(table_comment,'') <> '派车PDA拣货记录'), 'ALTER TABLE ll_dispatch_pda_pick COMMENT = ''派车PDA拣货记录''', '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_dispatch_pda_pick' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN id BIGINT 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_dispatch_pda_pick' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dispatch_pda_pick 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_dispatch_pda_pick' AND column_name='task_id' AND COALESCE(column_comment,'') <> 'PDA任务ID'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN task_id BIGINT NOT 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_dispatch_pda_pick' AND column_name='dispatch_item_id' AND COALESCE(column_comment,'') <> '派车明细ID'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN dispatch_item_id BIGINT 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_dispatch_pda_pick' AND column_name='inventory_position_id' AND COALESCE(column_comment,'') <> '库存仓位记录ID'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN inventory_position_id BIGINT 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_dispatch_pda_pick' AND column_name='packages' AND COALESCE(column_comment,'') <> '本次操作箱数'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN packages 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_dispatch_pda_pick' AND column_name='product_quantity' AND COALESCE(column_comment,'') <> '产品数量'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN product_quantity BIGINT 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_dispatch_pda_pick' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN idempotency_key VARCHAR(255) 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_dispatch_pda_pick' AND column_name='picked_at' AND COALESCE(column_comment,'') <> '拣货时间'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN picked_at DATETIME(3) 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_dispatch_pda_pick' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND COALESCE(table_comment,'') <> '派车PDA操作幂等记录'), 'ALTER TABLE ll_dispatch_pda_operation COMMENT = ''派车PDA操作幂等记录''', '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_dispatch_pda_operation' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN id BIGINT 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_dispatch_pda_operation' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dispatch_pda_operation 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_dispatch_pda_operation' AND column_name='task_id' AND COALESCE(column_comment,'') <> 'PDA任务ID'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN 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_dispatch_pda_operation' AND column_name='operation_type' AND COALESCE(column_comment,'') <> 'PDA操作类型'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN operation_type VARCHAR(32) NOT NULL COMMENT ''PDA操作类型''', '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_dispatch_pda_operation' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN idempotency_key VARCHAR(255) 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_dispatch_pda_operation' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN request_hash CHAR(64) 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_dispatch_pda_operation' AND column_name='result_json' AND COALESCE(column_comment,'') <> '处理结果JSON'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN result_json LONGTEXT NULL COMMENT ''处理结果JSON''', '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_dispatch_pda_operation' AND column_name='status' AND COALESCE(column_comment,'') <> 'PDA操作处理状态'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT ''PDA操作处理状态''', '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_dispatch_pda_operation' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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_dispatch_pda_operation' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_dispatch_pda_operation 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.tables WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND COALESCE(table_comment,'') <> 'PDA拣货订单归属记录'), 'ALTER TABLE ll_dispatch_pda_pick_order COMMENT = ''PDA拣货订单归属记录''', '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_dispatch_pda_pick_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN id BIGINT 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_dispatch_pda_pick_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dispatch_pda_pick_order 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_dispatch_pda_pick_order' AND column_name='pick_id' AND COALESCE(column_comment,'') <> 'PDA拣货记录ID'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN pick_id BIGINT NOT 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_dispatch_pda_pick_order' AND column_name='receipt_id' AND COALESCE(column_comment,'') <> '入仓实收记录ID'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN receipt_id BIGINT 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_dispatch_pda_pick_order' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN order_id BIGINT 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_dispatch_pda_pick_order' AND column_name='packages' AND COALESCE(column_comment,'') <> '本次操作箱数'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN packages 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_dispatch_pda_pick_order' AND column_name='product_quantity' AND COALESCE(column_comment,'') <> '产品数量'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN product_quantity BIGINT 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_dispatch_pda_pick_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND COALESCE(table_comment,'') <> '尾程出库单'), 'ALTER TABLE ll_outbound_order 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_outbound_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN id BIGINT 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_outbound_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_outbound_order 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_outbound_order' AND column_name='outbound_no' AND COALESCE(column_comment,'') <> '出库单号'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN outbound_no VARCHAR(64) 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_outbound_order' AND column_name='dispatch_order_id' AND COALESCE(column_comment,'') <> '派车单ID'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN dispatch_order_id BIGINT 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_outbound_order' AND column_name='dispatch_no' AND COALESCE(column_comment,'') <> '派车单号'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN dispatch_no VARCHAR(64) 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_outbound_order' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN overseas_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_outbound_order' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''WAIT_OUTBOUND'' 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_outbound_order' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN package_count 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_outbound_order' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '托盘数'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN pallet_count 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_outbound_order' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN idempotency_key VARCHAR(255) 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_outbound_order' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN created_by 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_outbound_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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_outbound_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_outbound_order 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.tables WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND COALESCE(table_comment,'') <> '出库托盘明细'), 'ALTER TABLE ll_outbound_pallet_item 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_outbound_pallet_item' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN id BIGINT 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_outbound_pallet_item' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_outbound_pallet_item 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_outbound_pallet_item' AND column_name='outbound_order_id' AND COALESCE(column_comment,'') <> '出库单ID'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN outbound_order_id BIGINT 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_outbound_pallet_item' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '仓库实物托盘ID'), 'ALTER TABLE ll_outbound_pallet_item 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_outbound_pallet_item' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '托盘号'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN pallet_no 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_outbound_pallet_item' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓ID'), 'ALTER TABLE ll_outbound_pallet_item 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_outbound_pallet_item' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_outbound_pallet_item 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_outbound_pallet_item' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称快照'), 'ALTER TABLE ll_outbound_pallet_item 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_outbound_pallet_item' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN package_count 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_outbound_pallet_item' AND column_name='order_refs' AND COALESCE(column_comment,'') <> '关联订单信息'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN order_refs VARCHAR(2000) 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_outbound_pallet_item' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN inbound_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_outbound_pallet_item' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND COALESCE(table_comment,'') <> '尾程派送单'), 'ALTER TABLE ll_delivery_order 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_delivery_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN id BIGINT 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_delivery_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_delivery_order 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_delivery_order' AND column_name='delivery_no' AND COALESCE(column_comment,'') <> '派送单号'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN delivery_no VARCHAR(64) 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_delivery_order' AND column_name='outbound_order_id' AND COALESCE(column_comment,'') <> '出库单ID'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN outbound_order_id BIGINT 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_delivery_order' AND column_name='outbound_no' AND COALESCE(column_comment,'') <> '出库单号'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN outbound_no VARCHAR(64) 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_delivery_order' AND column_name='dispatch_order_id' AND COALESCE(column_comment,'') <> '派车单ID'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN dispatch_order_id BIGINT 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_delivery_order' AND column_name='dispatch_no' AND COALESCE(column_comment,'') <> '派车单号'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN dispatch_no VARCHAR(64) 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_delivery_order' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN overseas_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_delivery_order' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''WAIT_DELIVERY'' 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_delivery_order' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN package_count 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_delivery_order' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '托盘数'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN pallet_count 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_delivery_order' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN idempotency_key VARCHAR(255) 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_delivery_order' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN created_by 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_delivery_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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_delivery_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_delivery_order 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.tables WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND COALESCE(table_comment,'') <> '客户转仓单'), 'ALTER TABLE ll_warehouse_transfer_order 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_warehouse_transfer_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN id BIGINT 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_warehouse_transfer_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_warehouse_transfer_order 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_warehouse_transfer_order' AND column_name='transfer_no' AND COALESCE(column_comment,'') <> '转仓单号'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN transfer_no VARCHAR(64) 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_warehouse_transfer_order' AND column_name='inbound_order_id' AND COALESCE(column_comment,'') <> '入仓单ID'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN inbound_order_id BIGINT 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_warehouse_transfer_order' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN inbound_code 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_warehouse_transfer_order' AND column_name='lastleg_order_id' AND COALESCE(column_comment,'') <> '尾程订单ID'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN lastleg_order_id BIGINT 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_warehouse_transfer_order' AND column_name='lastleg_order_no' AND COALESCE(column_comment,'') <> '尾程订单号'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN lastleg_order_no VARCHAR(32) 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_warehouse_transfer_order' AND column_name='operating_warehouse_id' AND COALESCE(column_comment,'') <> '作业仓库ID'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN operating_warehouse_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_warehouse_transfer_order' AND column_name='fba_warehouse_code' AND COALESCE(column_comment,'') <> 'FBA仓库编码'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_warehouse_code VARCHAR(128) NOT NULL COMMENT ''FBA仓库编码''', '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_warehouse_transfer_order' AND column_name='delivery_address' AND COALESCE(column_comment,'') <> '派送地址'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN delivery_address VARCHAR(1000) 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_warehouse_transfer_order' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN package_count INT 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_warehouse_transfer_order' AND column_name='delivery_method' AND COALESCE(column_comment,'') <> '派送方式'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN delivery_method VARCHAR(64) 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_warehouse_transfer_order' AND column_name='estimated_arrival_at' AND COALESCE(column_comment,'') <> '预计到达时间'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN estimated_arrival_at DATETIME(3) 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_warehouse_transfer_order' AND column_name='fba_shipment_no' AND COALESCE(column_comment,'') <> 'FBA货件编号'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_shipment_no VARCHAR(128) NOT NULL COMMENT ''FBA货件编号''', '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_warehouse_transfer_order' AND column_name='tracking_no' AND COALESCE(column_comment,'') <> '物流跟踪号'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN tracking_no 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_warehouse_transfer_order' AND column_name='fba_box_nos' AND COALESCE(column_comment,'') <> 'FBA箱号集合'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_box_nos VARCHAR(2000) NULL COMMENT ''FBA箱号集合''', '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_warehouse_transfer_order' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN remark VARCHAR(1000) 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_warehouse_transfer_order' AND column_name='need_relabel' AND COALESCE(column_comment,'') <> '是否需要换标'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN need_relabel TINYINT 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_warehouse_transfer_order' AND column_name='relabel_application_id' AND COALESCE(column_comment,'') <> '换标申请单ID'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN relabel_application_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_warehouse_transfer_order' AND column_name='relabel_no' AND COALESCE(column_comment,'') <> '换标申请单号'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN relabel_no VARCHAR(64) 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_warehouse_transfer_order' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_REVIEW'' 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_warehouse_transfer_order' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN idempotency_key VARCHAR(255) 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_warehouse_transfer_order' AND column_name='allocation_idempotency_key' AND COALESCE(column_comment,'') <> '库存分配幂等键'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN allocation_idempotency_key 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_warehouse_transfer_order' AND column_name='allocation_request_hash' AND COALESCE(column_comment,'') <> '库存分配请求哈希'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN allocation_request_hash CHAR(64) 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_warehouse_transfer_order' AND column_name='outbound_idempotency_key' AND COALESCE(column_comment,'') <> '出库幂等键'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN outbound_idempotency_key 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_warehouse_transfer_order' AND column_name='outbound_request_hash' AND COALESCE(column_comment,'') <> '出库请求哈希'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN outbound_request_hash CHAR(64) 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_warehouse_transfer_order' AND column_name='outbound_at' AND COALESCE(column_comment,'') <> '出库时间'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN outbound_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_warehouse_transfer_order' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN request_hash CHAR(64) 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_warehouse_transfer_order' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN created_by 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_warehouse_transfer_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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_warehouse_transfer_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_warehouse_transfer_order 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.tables WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND COALESCE(table_comment,'') <> '转仓来源历史订单绑定'), 'ALTER TABLE ll_warehouse_transfer_source_order 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_warehouse_transfer_source_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN id BIGINT 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_warehouse_transfer_source_order' AND column_name='transfer_order_id' AND COALESCE(column_comment,'') <> '转仓单ID'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN transfer_order_id BIGINT 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_warehouse_transfer_source_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_warehouse_transfer_source_order 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_warehouse_transfer_source_order' AND column_name='source_lastleg_order_id' AND COALESCE(column_comment,'') <> '来源尾程订单ID'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN source_lastleg_order_id BIGINT 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_warehouse_transfer_source_order' AND column_name='source_lastleg_order_no' AND COALESCE(column_comment,'') <> '来源尾程订单号'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN source_lastleg_order_no VARCHAR(32) 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_warehouse_transfer_source_order' AND column_name='inbound_order_id' AND COALESCE(column_comment,'') <> '入仓单ID'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN inbound_order_id BIGINT 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_warehouse_transfer_source_order' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN inbound_code 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_warehouse_transfer_source_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND COALESCE(table_comment,'') <> '转仓货物冻结明细'), 'ALTER TABLE ll_warehouse_transfer_item 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_warehouse_transfer_item' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN id BIGINT 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_warehouse_transfer_item' AND column_name='transfer_order_id' AND COALESCE(column_comment,'') <> '转仓单ID'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN transfer_order_id BIGINT 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_warehouse_transfer_item' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_warehouse_transfer_item 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_warehouse_transfer_item' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN sku_code VARCHAR(128) NOT 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_warehouse_transfer_item' AND column_name='product_name_zh' AND COALESCE(column_comment,'') <> '产品中文名称'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN product_name_zh VARCHAR(255) 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_warehouse_transfer_item' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN package_count INT 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_warehouse_transfer_item' AND column_name='shipping_mark' AND COALESCE(column_comment,'') <> '运输唛头'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN shipping_mark 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_warehouse_transfer_item' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND COALESCE(table_comment,'') <> '转仓单来源库存分配记录'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation 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_warehouse_transfer_inventory_allocation' AND column_name='id' AND COALESCE(column_comment,'') <> '雪花主键'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation 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_warehouse_transfer_inventory_allocation' AND column_name='transfer_order_id' AND COALESCE(column_comment,'') <> '转仓单ID'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN transfer_order_id BIGINT 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_warehouse_transfer_inventory_allocation' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation 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_warehouse_transfer_inventory_allocation' AND column_name='inventory_position_id' AND COALESCE(column_comment,'') <> '来源库存仓位记录ID'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN inventory_position_id BIGINT 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_warehouse_transfer_inventory_allocation' AND column_name='inventory_balance_id' AND COALESCE(column_comment,'') <> '来源库存余额记录ID'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN inventory_balance_id BIGINT 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_warehouse_transfer_inventory_allocation' AND column_name='inbound_order_id' AND COALESCE(column_comment,'') <> '来源入仓单ID'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN inbound_order_id BIGINT 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_warehouse_transfer_inventory_allocation' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '来源入仓单号快照'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN inbound_code 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_warehouse_transfer_inventory_allocation' AND column_name='pallet_id' AND COALESCE(column_comment,'') <> '来源托盘ID'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN pallet_id BIGINT 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_warehouse_transfer_inventory_allocation' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '来源托盘号快照'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN pallet_no 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_warehouse_transfer_inventory_allocation' AND column_name='package_count' AND COALESCE(column_comment,'') <> '分配箱数'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN package_count INT 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_warehouse_transfer_inventory_allocation' AND column_name='status' AND COALESCE(column_comment,'') <> '分配状态'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''RESERVED'' 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_warehouse_transfer_inventory_allocation' AND column_name='active_position_key' AND COALESCE(column_comment,'') <> '生效中的仓位排他键'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN active_position_key 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_warehouse_transfer_inventory_allocation' AND column_name='allocated_at' AND COALESCE(column_comment,'') <> '分配时间'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN allocated_at DATETIME(3) 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_warehouse_transfer_inventory_allocation' AND column_name='shipped_at' AND COALESCE(column_comment,'') <> '出库时间'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN shipped_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_warehouse_transfer_inventory_allocation' AND column_name='received_at' AND COALESCE(column_comment,'') <> '收货时间'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation 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_warehouse_transfer_inventory_allocation' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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_warehouse_transfer_inventory_allocation' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation 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.tables WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND COALESCE(table_comment,'') <> '转仓来源仓位的目标库存收货记录'), 'ALTER TABLE ll_warehouse_transfer_receipt 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_warehouse_transfer_receipt' AND column_name='id' AND COALESCE(column_comment,'') <> '雪花主键'), 'ALTER TABLE ll_warehouse_transfer_receipt 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_warehouse_transfer_receipt' AND column_name='transfer_order_id' AND COALESCE(column_comment,'') <> '转仓单ID'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN transfer_order_id BIGINT 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_warehouse_transfer_receipt' AND column_name='allocation_id' AND COALESCE(column_comment,'') <> '库存分配记录ID'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN allocation_id BIGINT 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_warehouse_transfer_receipt' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_warehouse_transfer_receipt 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_warehouse_transfer_receipt' AND column_name='source_inventory_position_id' AND COALESCE(column_comment,'') <> '来源库存仓位记录ID'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN source_inventory_position_id BIGINT 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_warehouse_transfer_receipt' AND column_name='destination_inventory_balance_id' AND COALESCE(column_comment,'') <> '目标库存余额记录ID'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN destination_inventory_balance_id BIGINT 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_warehouse_transfer_receipt' AND column_name='destination_inventory_position_id' AND COALESCE(column_comment,'') <> '目标库存仓位记录ID'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN destination_inventory_position_id BIGINT 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_warehouse_transfer_receipt' AND column_name='package_count' AND COALESCE(column_comment,'') <> '收货箱数'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN package_count INT 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_warehouse_transfer_receipt' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '收货幂等键'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN idempotency_key VARCHAR(255) 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_warehouse_transfer_receipt' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '收货请求哈希'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN request_hash CHAR(64) 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_warehouse_transfer_receipt' AND column_name='inventory_operation_key' AND COALESCE(column_comment,'') <> '库存操作幂等键'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN inventory_operation_key VARCHAR(255) 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_warehouse_transfer_receipt' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN created_by 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_warehouse_transfer_receipt' AND column_name='received_at' AND COALESCE(column_comment,'') <> '收货时间'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN received_at DATETIME(3) 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_warehouse_transfer_receipt' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND COALESCE(table_comment,'') <> '客户换标申请单'), 'ALTER TABLE ll_relabel_application 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_relabel_application' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN id BIGINT 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_relabel_application' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_relabel_application 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_relabel_application' AND column_name='relabel_no' AND COALESCE(column_comment,'') <> '换标申请单号'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN relabel_no VARCHAR(64) 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_relabel_application' AND column_name='lastleg_order_id' AND COALESCE(column_comment,'') <> '尾程订单ID'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN lastleg_order_id BIGINT 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_relabel_application' AND column_name='customer_order_no' AND COALESCE(column_comment,'') <> '客户订单号'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN customer_order_no 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_relabel_application' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN overseas_warehouse_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_relabel_application' AND column_name='label_deadline' AND COALESCE(column_comment,'') <> '换标截止时间'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN label_deadline DATETIME(3) 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_relabel_application' AND column_name='contact_name' AND COALESCE(column_comment,'') <> '联系人姓名'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN contact_name 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_relabel_application' AND column_name='contact_phone' AND COALESCE(column_comment,'') <> '联系人电话'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN contact_phone VARCHAR(64) 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_relabel_application' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN remark VARCHAR(1000) 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_relabel_application' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_PROCESSING'' 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_relabel_application' AND column_name='item_count' AND COALESCE(column_comment,'') <> '明细数量'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN item_count 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_relabel_application' AND column_name='total_package_count' AND COALESCE(column_comment,'') <> '总箱数'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN total_package_count 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_relabel_application' AND column_name='notified_package_count' AND COALESCE(column_comment,'') <> '已通知箱数'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN notified_package_count 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_relabel_application' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN idempotency_key VARCHAR(255) 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_relabel_application' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN request_hash CHAR(64) 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_relabel_application' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN created_by 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_relabel_application' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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_relabel_application' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_relabel_application 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.tables WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND COALESCE(table_comment,'') <> '换标货物冻结明细'), 'ALTER TABLE ll_relabel_item 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_relabel_item' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN id BIGINT 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_relabel_item' AND column_name='application_id' AND COALESCE(column_comment,'') <> '申请单ID'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN application_id BIGINT 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_relabel_item' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_relabel_item 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_relabel_item' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN sku_code VARCHAR(128) NOT 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_relabel_item' AND column_name='product_image_file_id' AND COALESCE(column_comment,'') <> '产品图片文件ID'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN product_image_file_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_relabel_item' AND column_name='product_image_file_name' AND COALESCE(column_comment,'') <> '产品图片文件名'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN product_image_file_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_relabel_item' AND column_name='product_image_url' AND COALESCE(column_comment,'') <> '产品图片地址'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN product_image_url VARCHAR(1000) 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_relabel_item' AND column_name='product_name_zh' AND COALESCE(column_comment,'') <> '产品中文名称'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN product_name_zh VARCHAR(255) 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_relabel_item' AND column_name='total_package_count' AND COALESCE(column_comment,'') <> '总箱数'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN total_package_count INT 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_relabel_item' AND column_name='notified_package_count' AND COALESCE(column_comment,'') <> '已通知箱数'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN notified_package_count INT 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_relabel_item' AND column_name='shipment_no' AND COALESCE(column_comment,'') <> '货件编号'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN shipment_no 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_relabel_item' AND column_name='tracking_code' AND COALESCE(column_comment,'') <> '物流跟踪编码'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN tracking_code 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_relabel_item' AND column_name='new_sku_code' AND COALESCE(column_comment,'') <> '换标后SKU编码'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN new_sku_code VARCHAR(128) NOT 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_relabel_item' AND column_name='label_requirement' AND COALESCE(column_comment,'') <> '换标要求'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN label_requirement VARCHAR(1000) 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_relabel_item' AND column_name='relabel_file_id' AND COALESCE(column_comment,'') <> '换标文件ID'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN relabel_file_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_relabel_item' AND column_name='relabel_file_name' AND COALESCE(column_comment,'') <> '换标文件名'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN relabel_file_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_relabel_item' AND column_name='relabel_file_url' AND COALESCE(column_comment,'') <> '换标文件地址'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN relabel_file_url VARCHAR(1000) 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_relabel_item' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND COALESCE(table_comment,'') <> '换标状态变更历史'), 'ALTER TABLE ll_relabel_status_history 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_relabel_status_history' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN id BIGINT 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_relabel_status_history' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_relabel_status_history 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_relabel_status_history' AND column_name='application_id' AND COALESCE(column_comment,'') <> '申请单ID'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN application_id BIGINT 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_relabel_status_history' AND column_name='from_status' AND COALESCE(column_comment,'') <> '变更前状态'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN from_status VARCHAR(32) 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_relabel_status_history' AND column_name='to_status' AND COALESCE(column_comment,'') <> '变更后状态'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN to_status VARCHAR(32) 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_relabel_status_history' AND column_name='operator_id' AND COALESCE(column_comment,'') <> '操作人ID'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN operator_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_relabel_status_history' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN remark VARCHAR(1000) 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_relabel_status_history' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND COALESCE(table_comment,'') <> '一件代发托盘档案'), 'ALTER TABLE ll_dropship_order_profile 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_dropship_order_profile' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN id BIGINT 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_dropship_order_profile' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dropship_order_profile 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_dropship_order_profile' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN order_id BIGINT 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_dropship_order_profile' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '仓库实物托盘ID'), 'ALTER TABLE ll_dropship_order_profile 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_dropship_order_profile' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '托盘号'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN pallet_no 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_dropship_order_profile' AND column_name='source_pallet_no' AND COALESCE(column_comment,'') <> '来源托盘号快照'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN source_pallet_no 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_dropship_order_profile' AND column_name='inventory_position_id' AND COALESCE(column_comment,'') <> '库存仓位记录ID'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN inventory_position_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_dropship_order_profile' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_INBOUND'' 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_dropship_order_profile' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN idempotency_key VARCHAR(255) 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_dropship_order_profile' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN version BIGINT 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_dropship_order_profile' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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_dropship_order_profile' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_dropship_order_profile 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.tables WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND COALESCE(table_comment,'') <> '一件代发箱唛明细'), 'ALTER TABLE ll_dropship_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_dropship_carton' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN id BIGINT 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_dropship_carton' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dropship_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_dropship_carton' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN order_id BIGINT 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_dropship_carton' AND column_name='profile_id' AND COALESCE(column_comment,'') <> '托盘档案ID'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN profile_id BIGINT 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_dropship_carton' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '仓库实物托盘ID'), 'ALTER TABLE ll_dropship_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_dropship_carton' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '托盘号'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN pallet_no 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_dropship_carton' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN sku_code VARCHAR(128) NOT 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_dropship_carton' AND column_name='carton_mark' AND COALESCE(column_comment,'') <> '箱唛'), 'ALTER TABLE ll_dropship_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_dropship_carton' AND column_name='carton_sequence' AND COALESCE(column_comment,'') <> '箱唛序号'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN carton_sequence INT 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_dropship_carton' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_INBOUND'' 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_dropship_carton' AND column_name='outbound_order_no' AND COALESCE(column_comment,'') <> '出库单号'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN outbound_order_no 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_dropship_carton' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN version BIGINT 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_dropship_carton' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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_dropship_carton' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_dropship_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.tables WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND COALESCE(table_comment,'') <> '一件代发SKU库存汇总'), 'ALTER TABLE ll_dropship_sku_inventory 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_dropship_sku_inventory' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN id BIGINT 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_dropship_sku_inventory' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dropship_sku_inventory 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_dropship_sku_inventory' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN order_id BIGINT 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_dropship_sku_inventory' AND column_name='profile_id' AND COALESCE(column_comment,'') <> '托盘档案ID'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN profile_id BIGINT 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_dropship_sku_inventory' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN sku_code VARCHAR(128) NOT 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_dropship_sku_inventory' AND column_name='product_name' AND COALESCE(column_comment,'') <> '产品名称'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN product_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_dropship_sku_inventory' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN package_count 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_dropship_sku_inventory' AND column_name='product_quantity' AND COALESCE(column_comment,'') <> '产品数量'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN product_quantity 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_dropship_sku_inventory' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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_dropship_sku_inventory' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_dropship_sku_inventory 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.tables WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND COALESCE(table_comment,'') <> '一件代发箱唛库存流水'), 'ALTER TABLE ll_dropship_carton_ledger 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_dropship_carton_ledger' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN id BIGINT 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_dropship_carton_ledger' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dropship_carton_ledger 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_dropship_carton_ledger' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN order_id BIGINT 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_dropship_carton_ledger' AND column_name='carton_id' AND COALESCE(column_comment,'') <> '箱唛记录ID'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN carton_id BIGINT 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_dropship_carton_ledger' AND column_name='carton_mark' AND COALESCE(column_comment,'') <> '箱唛'), 'ALTER TABLE ll_dropship_carton_ledger 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_dropship_carton_ledger' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN sku_code VARCHAR(128) NOT 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_dropship_carton_ledger' AND column_name='operation_type' AND COALESCE(column_comment,'') <> '操作类型'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN operation_type VARCHAR(32) 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_dropship_carton_ledger' AND column_name='before_packages' AND COALESCE(column_comment,'') <> '变动前箱数'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN before_packages 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_dropship_carton_ledger' AND column_name='delta_packages' AND COALESCE(column_comment,'') <> '箱数变动量'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN delta_packages 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_dropship_carton_ledger' AND column_name='after_packages' AND COALESCE(column_comment,'') <> '变动后箱数'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN after_packages 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_dropship_carton_ledger' AND column_name='business_order_no' AND COALESCE(column_comment,'') <> '业务单号'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN business_order_no 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_dropship_carton_ledger' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN idempotency_key VARCHAR(255) 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_dropship_carton_ledger' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN remark VARCHAR(1000) 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_dropship_carton_ledger' AND column_name='occurred_at' AND COALESCE(column_comment,'') <> '业务发生时间'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN occurred_at DATETIME(3) 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_dropship_carton_ledger' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND COALESCE(table_comment,'') <> '货物废弃申请单'), 'ALTER TABLE ll_cargo_discard_application 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_cargo_discard_application' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN id BIGINT 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_cargo_discard_application' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_cargo_discard_application 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_cargo_discard_application' AND column_name='discard_no' AND COALESCE(column_comment,'') <> '废弃申请单号'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN discard_no VARCHAR(64) 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_cargo_discard_application' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN overseas_warehouse_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_cargo_discard_application' AND column_name='overseas_warehouse_name' AND COALESCE(column_comment,'') <> '海外仓名称快照'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN overseas_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_cargo_discard_application' AND column_name='inbound_codes' AND COALESCE(column_comment,'') <> '入仓单号集合'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN inbound_codes VARCHAR(2000) 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_cargo_discard_application' AND column_name='total_packages' AND COALESCE(column_comment,'') <> '总箱数'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN total_packages INT 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_cargo_discard_application' AND column_name='fee_per_package' AND COALESCE(column_comment,'') <> '每箱处理费用'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN fee_per_package DECIMAL(18,2) 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_cargo_discard_application' AND column_name='total_fee' AND COALESCE(column_comment,'') <> '费用合计'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN total_fee DECIMAL(18,2) 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_cargo_discard_application' AND column_name='currency' AND COALESCE(column_comment,'') <> '币种'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN currency CHAR(3) NOT NULL DEFAULT ''USD'' 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_cargo_discard_application' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_REVIEW'' 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_cargo_discard_application' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN remark VARCHAR(1000) 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_cargo_discard_application' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN idempotency_key VARCHAR(255) 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_cargo_discard_application' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN request_hash CHAR(64) 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_cargo_discard_application' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN created_by 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_cargo_discard_application' AND column_name='reviewed_by' AND COALESCE(column_comment,'') <> '审核人ID'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN reviewed_by 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_cargo_discard_application' AND column_name='review_remark' AND COALESCE(column_comment,'') <> '审核备注'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN review_remark VARCHAR(1000) 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_cargo_discard_application' AND column_name='reviewed_at' AND COALESCE(column_comment,'') <> '审核时间'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN reviewed_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_cargo_discard_application' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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_cargo_discard_application' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_cargo_discard_application 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.tables WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND COALESCE(table_comment,'') <> '货物废弃SKU明细'), 'ALTER TABLE ll_cargo_discard_item 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_cargo_discard_item' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN id BIGINT 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_cargo_discard_item' AND column_name='application_id' AND COALESCE(column_comment,'') <> '申请单ID'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN application_id BIGINT 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_cargo_discard_item' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_cargo_discard_item 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_cargo_discard_item' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN order_id BIGINT 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_cargo_discard_item' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN inbound_code 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_cargo_discard_item' AND column_name='profile_id' AND COALESCE(column_comment,'') <> '托盘档案ID'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN profile_id BIGINT 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_cargo_discard_item' AND column_name='sku_inventory_id' AND COALESCE(column_comment,'') <> 'SKU库存记录ID'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN sku_inventory_id BIGINT NOT NULL COMMENT ''SKU库存记录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_cargo_discard_item' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN sku_code VARCHAR(128) NOT 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_cargo_discard_item' AND column_name='product_name' AND COALESCE(column_comment,'') <> '产品名称'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN product_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_cargo_discard_item' AND column_name='available_packages_snapshot' AND COALESCE(column_comment,'') <> '可用箱数快照'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN available_packages_snapshot INT 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_cargo_discard_item' AND column_name='discard_packages' AND COALESCE(column_comment,'') <> '废弃箱数'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN discard_packages INT 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_cargo_discard_item' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND COALESCE(table_comment,'') <> '货物废弃锁定箱明细'), 'ALTER TABLE ll_cargo_discard_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_cargo_discard_carton' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_cargo_discard_carton MODIFY COLUMN id BIGINT 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_cargo_discard_carton' AND column_name='application_id' AND COALESCE(column_comment,'') <> '申请单ID'), 'ALTER TABLE ll_cargo_discard_carton MODIFY COLUMN application_id BIGINT 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_cargo_discard_carton' AND column_name='item_id' AND COALESCE(column_comment,'') <> '明细ID'), 'ALTER TABLE ll_cargo_discard_carton MODIFY COLUMN item_id BIGINT 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_cargo_discard_carton' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_cargo_discard_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_cargo_discard_carton' AND column_name='carton_id' AND COALESCE(column_comment,'') <> '箱唛记录ID'), 'ALTER TABLE ll_cargo_discard_carton MODIFY COLUMN carton_id BIGINT 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_cargo_discard_carton' AND column_name='carton_mark' AND COALESCE(column_comment,'') <> '箱唛'), 'ALTER TABLE ll_cargo_discard_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_cargo_discard_carton' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_cargo_discard_carton MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_status_history' AND COALESCE(table_comment,'') <> '货物废弃审批历史'), 'ALTER TABLE ll_cargo_discard_status_history 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_cargo_discard_status_history' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN id BIGINT 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_cargo_discard_status_history' AND column_name='application_id' AND COALESCE(column_comment,'') <> '申请单ID'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN application_id BIGINT 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_cargo_discard_status_history' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_cargo_discard_status_history 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_cargo_discard_status_history' AND column_name='from_status' AND COALESCE(column_comment,'') <> '变更前状态'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN from_status VARCHAR(32) 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_cargo_discard_status_history' AND column_name='to_status' AND COALESCE(column_comment,'') <> '变更后状态'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN to_status VARCHAR(32) 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_cargo_discard_status_history' AND column_name='operator_id' AND COALESCE(column_comment,'') <> '操作人ID'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN operator_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_cargo_discard_status_history' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN remark VARCHAR(1000) 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_cargo_discard_status_history' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND COALESCE(table_comment,'') <> '海外采购订单'), 'ALTER TABLE ll_overseas_procurement_order 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_overseas_procurement_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN id BIGINT 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_overseas_procurement_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_overseas_procurement_order 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_overseas_procurement_order' AND column_name='customer_name' AND COALESCE(column_comment,'') <> '客户名称快照'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN customer_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_overseas_procurement_order' AND column_name='order_no' AND COALESCE(column_comment,'') <> '订单号'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN order_no VARCHAR(64) 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_overseas_procurement_order' AND column_name='purchase_category' AND COALESCE(column_comment,'') <> '采购品类'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN purchase_category 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_overseas_procurement_order' AND column_name='product_name' AND COALESCE(column_comment,'') <> '产品名称'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN product_name VARCHAR(255) 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_overseas_procurement_order' AND column_name='product_specification' AND COALESCE(column_comment,'') <> '产品规格'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN product_specification VARCHAR(500) 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_overseas_procurement_order' AND column_name='product_url' AND COALESCE(column_comment,'') <> '产品链接'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN product_url VARCHAR(2000) 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_overseas_procurement_order' AND column_name='purchase_quantity' AND COALESCE(column_comment,'') <> '采购数量'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN purchase_quantity 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_overseas_procurement_order' AND column_name='budget_unit_price' AND COALESCE(column_comment,'') <> '预算单价'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN budget_unit_price DECIMAL(18,2) 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_overseas_procurement_order' AND column_name='currency' AND COALESCE(column_comment,'') <> '币种'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN currency CHAR(3) NOT NULL DEFAULT ''USD'' 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_overseas_procurement_order' AND column_name='receiving_address' AND COALESCE(column_comment,'') <> '收货地址'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN receiving_address VARCHAR(1000) 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_overseas_procurement_order' AND column_name='contact_name' AND COALESCE(column_comment,'') <> '联系人姓名'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN contact_name 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_overseas_procurement_order' AND column_name='contact_phone' AND COALESCE(column_comment,'') <> '联系人电话'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN contact_phone VARCHAR(64) 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_overseas_procurement_order' AND column_name='purchase_warehouse_id' AND COALESCE(column_comment,'') <> '采购入仓仓库ID'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN purchase_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_overseas_procurement_order' AND column_name='purchase_warehouse_name' AND COALESCE(column_comment,'') <> '采购入仓仓库名称'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN purchase_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_overseas_procurement_order' AND column_name='inbound_quantity' AND COALESCE(column_comment,'') <> '累计入库数量'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN inbound_quantity BIGINT 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_overseas_procurement_order' AND column_name='outbound_quantity' AND COALESCE(column_comment,'') <> '累计出库数量'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN outbound_quantity BIGINT 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_overseas_procurement_order' AND column_name='domestic_carrier' AND COALESCE(column_comment,'') <> '境内承运商'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN domestic_carrier 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_overseas_procurement_order' AND column_name='domestic_tracking_no' AND COALESCE(column_comment,'') <> '境内物流单号'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN domestic_tracking_no 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_overseas_procurement_order' AND column_name='overseas_carrier' AND COALESCE(column_comment,'') <> '海外承运商'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN overseas_carrier 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_overseas_procurement_order' AND column_name='overseas_tracking_no' AND COALESCE(column_comment,'') <> '海外物流单号'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN overseas_tracking_no 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_overseas_procurement_order' AND column_name='estimated_arrival_at' AND COALESCE(column_comment,'') <> '预计到达时间'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN estimated_arrival_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_overseas_procurement_order' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_REVIEW'' 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_overseas_procurement_order' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN remark VARCHAR(1000) 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_overseas_procurement_order' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN idempotency_key VARCHAR(255) 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_overseas_procurement_order' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN request_hash CHAR(64) 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_overseas_procurement_order' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN created_by 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_overseas_procurement_order' AND column_name='reviewed_by' AND COALESCE(column_comment,'') <> '审核人ID'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN reviewed_by 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_overseas_procurement_order' AND column_name='review_remark' AND COALESCE(column_comment,'') <> '审核备注'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN review_remark VARCHAR(1000) 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_overseas_procurement_order' AND column_name='reviewed_at' AND COALESCE(column_comment,'') <> '审核时间'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN reviewed_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_overseas_procurement_order' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN version BIGINT 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_overseas_procurement_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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_overseas_procurement_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_overseas_procurement_order 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.tables WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND COALESCE(table_comment,'') <> '海外采购出库记录'), 'ALTER TABLE ll_overseas_procurement_outbound 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_overseas_procurement_outbound' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN id BIGINT 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_overseas_procurement_outbound' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN order_id BIGINT 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_overseas_procurement_outbound' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_overseas_procurement_outbound 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_overseas_procurement_outbound' AND column_name='quantity' AND COALESCE(column_comment,'') <> '数量'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN quantity 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_overseas_procurement_outbound' AND column_name='outbound_no' AND COALESCE(column_comment,'') <> '出库单号'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN outbound_no 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_overseas_procurement_outbound' AND column_name='carrier' AND COALESCE(column_comment,'') <> '承运商'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN carrier 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_overseas_procurement_outbound' AND column_name='tracking_no' AND COALESCE(column_comment,'') <> '物流跟踪号'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN tracking_no 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_overseas_procurement_outbound' AND column_name='outbound_at' AND COALESCE(column_comment,'') <> '出库时间'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN outbound_at DATETIME(3) 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_overseas_procurement_outbound' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN remark VARCHAR(1000) 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_overseas_procurement_outbound' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN created_by 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_overseas_procurement_outbound' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND COALESCE(table_comment,'') <> '海外采购审核历史'), 'ALTER TABLE ll_overseas_procurement_status_history 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_overseas_procurement_status_history' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN id BIGINT 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_overseas_procurement_status_history' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN order_id BIGINT 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_overseas_procurement_status_history' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_overseas_procurement_status_history 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_overseas_procurement_status_history' AND column_name='from_status' AND COALESCE(column_comment,'') <> '变更前状态'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN from_status VARCHAR(32) 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_overseas_procurement_status_history' AND column_name='to_status' AND COALESCE(column_comment,'') <> '变更后状态'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN to_status VARCHAR(32) 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_overseas_procurement_status_history' AND column_name='operator_id' AND COALESCE(column_comment,'') <> '操作人ID'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN operator_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_overseas_procurement_status_history' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN remark VARCHAR(1000) 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_overseas_procurement_status_history' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND COALESCE(table_comment,'') <> '美国卡车运输订单'), 'ALTER TABLE ll_us_truck_order 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_us_truck_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN id BIGINT 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_us_truck_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_us_truck_order 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_us_truck_order' AND column_name='order_no' AND COALESCE(column_comment,'') <> '订单号'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN order_no VARCHAR(64) 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_us_truck_order' AND column_name='transport_type' AND COALESCE(column_comment,'') <> '运输类型'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN transport_type VARCHAR(32) 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_us_truck_order' AND column_name='origin' AND COALESCE(column_comment,'') <> '起运地'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN origin VARCHAR(255) 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_us_truck_order' AND column_name='origin_address' AND COALESCE(column_comment,'') <> '起运地址'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN origin_address VARCHAR(500) 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_us_truck_order' AND column_name='destination' AND COALESCE(column_comment,'') <> '目的地'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN destination VARCHAR(255) 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_us_truck_order' AND column_name='destination_address' AND COALESCE(column_comment,'') <> '目的地址'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN destination_address VARCHAR(500) 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_us_truck_order' AND column_name='container_type' AND COALESCE(column_comment,'') <> '历史兼容设备类型,零担托盘业务不采集'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN container_type VARCHAR(32) 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_us_truck_order' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '客户申报托盘数'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 1 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_us_truck_order' AND column_name='package_count' AND COALESCE(column_comment,'') <> '可选箱数,零或空值均有效'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN package_count INT 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_us_truck_order' AND column_name='received_pallet_count' AND COALESCE(column_comment,'') <> 'PDA实收托盘数'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN received_pallet_count INT NOT NULL DEFAULT 0 COMMENT ''PDA实收托盘数''', '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_us_truck_order' AND column_name='allocated_pallet_count' AND COALESCE(column_comment,'') <> '已分配到合车行程的托盘数'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN allocated_pallet_count 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_us_truck_order' AND column_name='shipped_pallet_count' AND COALESCE(column_comment,'') <> '已出库托盘数'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN shipped_pallet_count 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_us_truck_order' AND column_name='total_weight_kg' AND COALESCE(column_comment,'') <> '总重量(千克)'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN total_weight_kg DECIMAL(18,3) 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_us_truck_order' AND column_name='total_volume_cbm' AND COALESCE(column_comment,'') <> '总体积(立方米)'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN total_volume_cbm DECIMAL(18,6) 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_us_truck_order' AND column_name='budget_amount' AND COALESCE(column_comment,'') <> '预算金额'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN budget_amount DECIMAL(18,2) 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_us_truck_order' AND column_name='currency' AND COALESCE(column_comment,'') <> '币种'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN currency VARCHAR(3) NOT NULL DEFAULT ''USD'' 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_us_truck_order' AND column_name='contact_name' AND COALESCE(column_comment,'') <> '联系人姓名'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN contact_name 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_us_truck_order' AND column_name='contact_info' AND COALESCE(column_comment,'') <> '联系人联系方式'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN contact_info 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_us_truck_order' AND column_name='requested_date' AND COALESCE(column_comment,'') <> '要求服务日期'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN requested_date DATE 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_us_truck_order' AND column_name='status' AND COALESCE(column_comment,'') <> '美国卡车订单状态'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN status VARCHAR(32) 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_us_truck_order' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN remark VARCHAR(1000) 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_us_truck_order' AND column_name='receipt_idempotency_key' AND COALESCE(column_comment,'') <> '实物收货幂等键'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN receipt_idempotency_key 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_us_truck_order' AND column_name='receipt_request_hash' AND COALESCE(column_comment,'') <> '实物收货请求哈希'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN receipt_request_hash CHAR(64) 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_us_truck_order' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN idempotency_key VARCHAR(255) 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_us_truck_order' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN request_hash CHAR(64) 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_us_truck_order' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN created_by 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_us_truck_order' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN version BIGINT 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_us_truck_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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_us_truck_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT 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.tables WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND COALESCE(table_comment,'') <> '美国卡车订单托盘实收记录'), 'ALTER TABLE ll_us_truck_order_pallet 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_us_truck_order_pallet' AND column_name='id' AND COALESCE(column_comment,'') <> '托盘记录ID'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN id BIGINT 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_us_truck_order_pallet' AND column_name='order_id' AND COALESCE(column_comment,'') <> '美国卡车订单ID'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN order_id BIGINT 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_us_truck_order_pallet' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_us_truck_order_pallet 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_us_truck_order_pallet' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '业务托盘号'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN pallet_no 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_us_truck_order_pallet' AND column_name='sequence_no' AND COALESCE(column_comment,'') <> '订单内托盘序号'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN sequence_no INT 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_us_truck_order_pallet' AND column_name='source' AND COALESCE(column_comment,'') <> '托盘来源:订单计划或PDA超收'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN source VARCHAR(32) NOT NULL COMMENT ''托盘来源:订单计划或PDA超收''', '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_us_truck_order_pallet' AND column_name='status' AND COALESCE(column_comment,'') <> '托盘状态:待收、已收或超收待审核'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN status VARCHAR(32) 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_us_truck_order_pallet' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '仓库实物托盘资源ID'), 'ALTER TABLE ll_us_truck_order_pallet 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_us_truck_order_pallet' AND column_name='package_count' AND COALESCE(column_comment,'') <> '托盘箱数'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN package_count INT 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_us_truck_order_pallet' AND column_name='received_at' AND COALESCE(column_comment,'') <> '实收时间'), 'ALTER TABLE ll_us_truck_order_pallet 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_us_truck_order_pallet' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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_us_truck_order_pallet' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_us_truck_order_pallet 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.tables WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_status_history' AND COALESCE(table_comment,'') <> '美国卡车订单状态历史'), 'ALTER TABLE ll_us_truck_order_status_history 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_us_truck_order_status_history' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN id BIGINT 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_us_truck_order_status_history' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN order_id BIGINT 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_us_truck_order_status_history' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_us_truck_order_status_history 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_us_truck_order_status_history' AND column_name='from_status' AND COALESCE(column_comment,'') <> '变更前状态'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN from_status VARCHAR(32) 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_us_truck_order_status_history' AND column_name='to_status' AND COALESCE(column_comment,'') <> '变更后状态'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN to_status VARCHAR(32) 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_us_truck_order_status_history' AND column_name='operator_id' AND COALESCE(column_comment,'') <> '操作人ID'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN operator_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_us_truck_order_status_history' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN remark VARCHAR(1000) 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_us_truck_order_status_history' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + + +USE baiyue_warehouse_overseas; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='wh_location' AND COALESCE(table_comment,'') <> '仓库库位表:按租户和仓库维护库位及承重信息'), 'ALTER TABLE wh_location 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='wh_location' AND column_name='id' AND COALESCE(column_comment,'') <> '库位ID(雪花ID)'), 'ALTER TABLE wh_location MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT ''库位ID(雪花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='wh_location' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '数据所属租户ID'), 'ALTER TABLE wh_location MODIFY COLUMN customer_id BIGINT UNSIGNED 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='wh_location' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '所属仓库ID'), 'ALTER TABLE wh_location MODIFY COLUMN warehouse_id BIGINT UNSIGNED 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='wh_location' AND column_name='zone_code' AND COALESCE(column_comment,'') <> '仓库分区编码'), 'ALTER TABLE wh_location MODIFY COLUMN zone_code VARCHAR(50) 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='wh_location' AND column_name='location_code' AND COALESCE(column_comment,'') <> '仓库内唯一库位编码'), 'ALTER TABLE wh_location MODIFY COLUMN location_code VARCHAR(64) 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='wh_location' AND column_name='location_type' AND COALESCE(column_comment,'') <> '库位类型'), 'ALTER TABLE wh_location MODIFY COLUMN location_type VARCHAR(32) 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='wh_location' AND column_name='max_load_kg' AND COALESCE(column_comment,'') <> '最大承重(千克)'), 'ALTER TABLE wh_location MODIFY COLUMN max_load_kg DECIMAL(18,3) 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='wh_location' AND column_name='status' AND COALESCE(column_comment,'') <> '库位状态:ACTIVE启用、INACTIVE停用'), 'ALTER TABLE wh_location MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''库位状态:ACTIVE启用、INACTIVE停用''', '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='wh_location' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE wh_location MODIFY COLUMN remark VARCHAR(500) DEFAULT 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='wh_location' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE wh_location MODIFY COLUMN created_by BIGINT UNSIGNED DEFAULT 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='wh_location' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_location MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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='wh_location' AND column_name='updated_by' AND COALESCE(column_comment,'') <> '最后修改人ID'), 'ALTER TABLE wh_location MODIFY COLUMN updated_by BIGINT UNSIGNED DEFAULT 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='wh_location' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE wh_location 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='wh_location' AND column_name='deleted' AND COALESCE(column_comment,'') <> '逻辑删除标记'), 'ALTER TABLE wh_location MODIFY COLUMN deleted TINYINT(1) 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='wh_location' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本号'), 'ALTER TABLE wh_location 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.tables WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND COALESCE(table_comment,'') <> '仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'), 'ALTER TABLE wh_pallet 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='wh_pallet' AND column_name='id' AND COALESCE(column_comment,'') <> '托盘ID(雪花ID)'), 'ALTER TABLE wh_pallet MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT ''托盘ID(雪花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='wh_pallet' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '数据所属租户ID'), 'ALTER TABLE wh_pallet MODIFY COLUMN customer_id BIGINT UNSIGNED 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='wh_pallet' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '所属仓库ID'), 'ALTER TABLE wh_pallet MODIFY COLUMN warehouse_id BIGINT UNSIGNED 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='wh_pallet' AND column_name='location_id' AND COALESCE(column_comment,'') <> '同仓库内绑定的库位ID,可为空'), 'ALTER TABLE wh_pallet MODIFY COLUMN location_id BIGINT UNSIGNED DEFAULT 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='wh_pallet' AND column_name='pallet_code' AND COALESCE(column_comment,'') <> '仓库内唯一托盘编码'), 'ALTER TABLE wh_pallet MODIFY COLUMN pallet_code 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='wh_pallet' AND column_name='pallet_type' AND COALESCE(column_comment,'') <> '托盘类型'), 'ALTER TABLE wh_pallet MODIFY COLUMN pallet_type VARCHAR(32) 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='wh_pallet' AND column_name='capacity_cbm' AND COALESCE(column_comment,'') <> '托盘容量(立方米)'), 'ALTER TABLE wh_pallet MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 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='wh_pallet' AND column_name='unit_price' AND COALESCE(column_comment,'') <> '托盘单价'), 'ALTER TABLE wh_pallet MODIFY COLUMN unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 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='wh_pallet' AND column_name='status' AND COALESCE(column_comment,'') <> '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃'), 'ALTER TABLE wh_pallet MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT ''IDLE'' COMMENT ''托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃''', '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='wh_pallet' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE wh_pallet MODIFY COLUMN remark VARCHAR(500) DEFAULT 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='wh_pallet' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE wh_pallet MODIFY COLUMN created_by BIGINT UNSIGNED DEFAULT 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='wh_pallet' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_pallet MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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='wh_pallet' AND column_name='updated_by' AND COALESCE(column_comment,'') <> '最后修改人ID'), 'ALTER TABLE wh_pallet MODIFY COLUMN updated_by BIGINT UNSIGNED DEFAULT 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='wh_pallet' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE wh_pallet 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='wh_pallet' AND column_name='deleted' AND COALESCE(column_comment,'') <> '逻辑删除标记'), 'ALTER TABLE wh_pallet MODIFY COLUMN deleted TINYINT(1) 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='wh_pallet' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本号'), 'ALTER TABLE wh_pallet 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.tables WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND COALESCE(table_comment,'') <> '仓储资源锁定记录'), 'ALTER TABLE wh_resource_lock 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='wh_resource_lock' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN id BIGINT UNSIGNED 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='wh_resource_lock' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN customer_id BIGINT UNSIGNED 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='wh_resource_lock' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '仓库ID'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN warehouse_id BIGINT UNSIGNED 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='wh_resource_lock' AND column_name='pallet_id' AND COALESCE(column_comment,'') <> '托盘ID'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN pallet_id BIGINT UNSIGNED 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='wh_resource_lock' AND column_name='location_id' AND COALESCE(column_comment,'') <> '库位ID'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN location_id BIGINT UNSIGNED 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='wh_resource_lock' AND column_name='business_type' AND COALESCE(column_comment,'') <> '业务类型'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN business_type VARCHAR(50) 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='wh_resource_lock' AND column_name='business_id' AND COALESCE(column_comment,'') <> '业务记录ID'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN business_id VARCHAR(100) 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='wh_resource_lock' AND column_name='reservation_key' AND COALESCE(column_comment,'') <> '资源预留幂等键'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN reservation_key VARCHAR(160) 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='wh_resource_lock' AND column_name='status' AND COALESCE(column_comment,'') <> '资源锁状态'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' 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='wh_resource_lock' AND column_name='locked_at' AND COALESCE(column_comment,'') <> '锁定时间'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN locked_at DATETIME(3) 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='wh_resource_lock' AND column_name='released_at' AND COALESCE(column_comment,'') <> '释放时间'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN released_at DATETIME(3) DEFAULT 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='wh_resource_lock' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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='wh_resource_lock' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE wh_resource_lock 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.tables WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND COALESCE(table_comment,'') <> '仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态'), 'ALTER TABLE wh_warehouse 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='wh_warehouse' AND column_name='id' AND COALESCE(column_comment,'') <> '仓库ID(雪花ID)'), 'ALTER TABLE wh_warehouse MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT ''仓库ID(雪花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='wh_warehouse' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '仓库所属租户ID'), 'ALTER TABLE wh_warehouse MODIFY COLUMN customer_id BIGINT UNSIGNED 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='wh_warehouse' AND column_name='warehouse_code' AND COALESCE(column_comment,'') <> '租户内唯一仓库编码'), 'ALTER TABLE wh_warehouse MODIFY COLUMN warehouse_code VARCHAR(50) 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='wh_warehouse' AND column_name='warehouse_name' AND COALESCE(column_comment,'') <> '仓库名称'), 'ALTER TABLE wh_warehouse MODIFY COLUMN warehouse_name VARCHAR(120) 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='wh_warehouse' AND column_name='warehouse_type' AND COALESCE(column_comment,'') <> '仓库类型'), 'ALTER TABLE wh_warehouse MODIFY COLUMN warehouse_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' 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='wh_warehouse' AND column_name='country_code' AND COALESCE(column_comment,'') <> '所在国家二字码'), 'ALTER TABLE wh_warehouse MODIFY COLUMN country_code CHAR(2) 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='wh_warehouse' AND column_name='timezone' AND COALESCE(column_comment,'') <> '仓库所在地IANA时区'), 'ALTER TABLE wh_warehouse MODIFY COLUMN timezone VARCHAR(64) NOT NULL COMMENT ''仓库所在地IANA时区''', '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='wh_warehouse' AND column_name='contact_name' AND COALESCE(column_comment,'') <> '仓库联系人'), 'ALTER TABLE wh_warehouse MODIFY COLUMN contact_name VARCHAR(64) DEFAULT 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='wh_warehouse' AND column_name='contact_phone' AND COALESCE(column_comment,'') <> '联系电话'), 'ALTER TABLE wh_warehouse MODIFY COLUMN contact_phone VARCHAR(32) DEFAULT 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='wh_warehouse' AND column_name='contact_email' AND COALESCE(column_comment,'') <> '联系邮箱'), 'ALTER TABLE wh_warehouse MODIFY COLUMN contact_email VARCHAR(128) DEFAULT 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='wh_warehouse' AND column_name='status' AND COALESCE(column_comment,'') <> '仓库状态'), 'ALTER TABLE wh_warehouse MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' 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='wh_warehouse' AND column_name='remark' AND COALESCE(column_comment,'') <> '仓库备注'), 'ALTER TABLE wh_warehouse MODIFY COLUMN remark VARCHAR(500) DEFAULT 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='wh_warehouse' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE wh_warehouse MODIFY COLUMN created_by BIGINT UNSIGNED DEFAULT 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='wh_warehouse' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_warehouse MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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='wh_warehouse' AND column_name='updated_by' AND COALESCE(column_comment,'') <> '最后修改人ID'), 'ALTER TABLE wh_warehouse MODIFY COLUMN updated_by BIGINT UNSIGNED DEFAULT 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='wh_warehouse' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE wh_warehouse 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='wh_warehouse' AND column_name='deleted' AND COALESCE(column_comment,'') <> '逻辑删除标记'), 'ALTER TABLE wh_warehouse MODIFY COLUMN deleted TINYINT(1) 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='wh_warehouse' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本号'), 'ALTER TABLE wh_warehouse 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.tables WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND COALESCE(table_comment,'') <> '仓库地址表:保存运营、退货等不同用途的地址及可选经纬度'), 'ALTER TABLE wh_warehouse_address 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='wh_warehouse_address' AND column_name='id' AND COALESCE(column_comment,'') <> '仓库地址ID'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN id BIGINT UNSIGNED 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='wh_warehouse_address' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '数据所属租户ID'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN customer_id BIGINT UNSIGNED 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='wh_warehouse_address' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '所属仓库ID'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN warehouse_id BIGINT UNSIGNED 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='wh_warehouse_address' AND column_name='address_type' AND COALESCE(column_comment,'') <> '地址类型,例如OPERATING或RETURN'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN address_type VARCHAR(20) NOT NULL DEFAULT ''OPERATING'' COMMENT ''地址类型,例如OPERATING或RETURN''', '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='wh_warehouse_address' AND column_name='country_code' AND COALESCE(column_comment,'') <> '国家二字码'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN country_code CHAR(2) 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='wh_warehouse_address' AND column_name='state_province' AND COALESCE(column_comment,'') <> '省或州'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN state_province VARCHAR(100) DEFAULT 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='wh_warehouse_address' AND column_name='city' AND COALESCE(column_comment,'') <> '城市'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN city VARCHAR(100) 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='wh_warehouse_address' AND column_name='district' AND COALESCE(column_comment,'') <> '区县'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN district VARCHAR(100) DEFAULT 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='wh_warehouse_address' AND column_name='postal_code' AND COALESCE(column_comment,'') <> '邮政编码'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN postal_code VARCHAR(20) DEFAULT 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='wh_warehouse_address' AND column_name='address_line1' AND COALESCE(column_comment,'') <> '主地址行'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN address_line1 VARCHAR(255) 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='wh_warehouse_address' AND column_name='address_line2' AND COALESCE(column_comment,'') <> '补充地址行'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN address_line2 VARCHAR(255) DEFAULT 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='wh_warehouse_address' AND column_name='latitude' AND COALESCE(column_comment,'') <> '纬度'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN latitude DECIMAL(10,7) DEFAULT 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='wh_warehouse_address' AND column_name='longitude' AND COALESCE(column_comment,'') <> '经度'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN longitude DECIMAL(10,7) DEFAULT 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='wh_warehouse_address' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN created_by BIGINT UNSIGNED DEFAULT 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='wh_warehouse_address' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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='wh_warehouse_address' AND column_name='updated_by' AND COALESCE(column_comment,'') <> '最后修改人ID'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN updated_by BIGINT UNSIGNED DEFAULT 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='wh_warehouse_address' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE wh_warehouse_address 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='wh_warehouse_address' AND column_name='deleted' AND COALESCE(column_comment,'') <> '逻辑删除标记'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN deleted TINYINT(1) 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='wh_warehouse_address' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本号'), 'ALTER TABLE wh_warehouse_address 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.tables WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND COALESCE(table_comment,'') <> '仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间'), 'ALTER TABLE wh_service_capability 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='wh_service_capability' AND column_name='id' AND COALESCE(column_comment,'') <> '服务能力ID'), 'ALTER TABLE wh_service_capability MODIFY COLUMN id BIGINT UNSIGNED 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='wh_service_capability' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '数据所属租户ID'), 'ALTER TABLE wh_service_capability MODIFY COLUMN customer_id BIGINT UNSIGNED 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='wh_service_capability' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '所属仓库ID'), 'ALTER TABLE wh_service_capability MODIFY COLUMN warehouse_id BIGINT UNSIGNED 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='wh_service_capability' AND column_name='capability_code' AND COALESCE(column_comment,'') <> '能力编码:RECEIVING、STORAGE、PICK_PACK、RETURN等'), 'ALTER TABLE wh_service_capability MODIFY COLUMN capability_code VARCHAR(50) NOT NULL COMMENT ''能力编码:RECEIVING、STORAGE、PICK_PACK、RETURN等''', '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='wh_service_capability' AND column_name='capability_name' AND COALESCE(column_comment,'') <> '能力名称'), 'ALTER TABLE wh_service_capability MODIFY COLUMN capability_name VARCHAR(100) 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='wh_service_capability' AND column_name='enabled' AND COALESCE(column_comment,'') <> '是否启用'), 'ALTER TABLE wh_service_capability MODIFY COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1 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='wh_service_capability' AND column_name='cutoff_time' AND COALESCE(column_comment,'') <> '当日作业截单时间'), 'ALTER TABLE wh_service_capability MODIFY COLUMN cutoff_time TIME DEFAULT 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='wh_service_capability' AND column_name='description' AND COALESCE(column_comment,'') <> '能力说明'), 'ALTER TABLE wh_service_capability MODIFY COLUMN description VARCHAR(500) DEFAULT 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='wh_service_capability' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE wh_service_capability MODIFY COLUMN created_by BIGINT UNSIGNED DEFAULT 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='wh_service_capability' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_service_capability MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT 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='wh_service_capability' AND column_name='updated_by' AND COALESCE(column_comment,'') <> '最后修改人ID'), 'ALTER TABLE wh_service_capability MODIFY COLUMN updated_by BIGINT UNSIGNED DEFAULT 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='wh_service_capability' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE wh_service_capability 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='wh_service_capability' AND column_name='deleted' AND COALESCE(column_comment,'') <> '逻辑删除标记'), 'ALTER TABLE wh_service_capability MODIFY COLUMN deleted TINYINT(1) 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='wh_service_capability' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本号'), 'ALTER TABLE wh_service_capability 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.tables WHERE table_schema=DATABASE() AND table_name='wh_pallet_business_binding' AND COALESCE(table_comment,'') <> '托盘业务绑定关系'), 'ALTER TABLE wh_pallet_business_binding 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='wh_pallet_business_binding' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN id BIGINT 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='wh_pallet_business_binding' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN customer_id BIGINT 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='wh_pallet_business_binding' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '仓库ID'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN warehouse_id BIGINT 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='wh_pallet_business_binding' AND column_name='business_type' AND COALESCE(column_comment,'') <> '业务类型'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN business_type VARCHAR(32) 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='wh_pallet_business_binding' AND column_name='business_id' AND COALESCE(column_comment,'') <> '绑定业务记录ID'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN business_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='wh_pallet_business_binding' AND column_name='pallet_id' AND COALESCE(column_comment,'') <> '托盘ID'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN pallet_id BIGINT 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='wh_pallet_business_binding' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +-- ============================================================================ +-- 2026-08-19 / 026 +-- Separate tail-leg main workflow status from child-document status +-- ============================================================================ +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='sub_order_status'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN sub_order_status VARCHAR(255) NULL COMMENT ''子单据状态汇总'' AFTER current_status')); +PREPARE ll_order_sub_status_026 FROM @ddl; EXECUTE ll_order_sub_status_026; DEALLOCATE PREPARE ll_order_sub_status_026; diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md new file mode 100644 index 000000000..e3b1498dc --- /dev/null +++ b/warehouse-overseas-service/AGENTS.md @@ -0,0 +1,45 @@ +# Warehouse Service 模块指引 + +## 职责与边界 + +- 负责海外仓库、地址、服务能力、库位、托盘及业务资源锁定/释放。 +- 尾程入仓计划通过内部网关请求资源;本模块不读取尾程订单库或头程数据库。 + +## 快速索引 + +| 功能 | 入口 | +|---|---| +| 平台仓库管理 | `src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java` | +| 商户仓库查询 | `controller/MerchantWarehouseController.java` | +| 平台/商户库位与托盘 | `controller/PlatformWarehouseResourceController.java`、`MerchantWarehouseResourceController.java` | +| 内部资源锁定、释放、报废 | `controller/InternalWarehouseResourceLockController.java` | +| 仓库与资源服务 | `service/WarehouseApplicationService.java`、`WarehouseResourceService.java`、`WarehouseResourceLockService.java` | +| 内部服务令牌 | `security/InternalServiceTokenGuard.java` | +| 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` | +| 测试 | `src/test/java/com/baiyue/warehouse/` | + +## 数据与安全规则 + +- 平台和商户接口必须分别执行访问域与租户范围校验;客户端传入的 `tenantId` 不能作为唯一授权依据。 +- 资源锁定以业务幂等键和 reservation 标识防重;释放/报废操作必须验证当前状态。 +- 内部资源接口必须校验服务令牌;普通用户不得直接调用。 + +## 数据库表索引与注释约束 + +| 业务域 | 表名 | 中文含义 | +|---|---|---| +| 库位 | `wh_location` | 海外仓库位资源 | +| 托盘 | `wh_pallet` | 海外仓实物托盘资源 | +| 资源锁 | `wh_resource_lock` | 仓储资源锁定记录 | +| 仓库 | `wh_warehouse` | 海外仓档案 | +| 仓库地址 | `wh_warehouse_address` | 海外仓地址 | +| 服务能力 | `wh_service_capability` | 海外仓服务能力 | +| 托盘绑定 | `wh_pallet_business_binding` | 托盘业务绑定关系 | + +- 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义。 +- `SchemaChineseCommentContractTest` 自动扫描全部 `wh_` 表;新增字段必须同步更新基线 SQL、统一增量 SQL 和存量备注迁移。 + +## 修改与验证 + +- 字段、注释和索引同步维护基线 SQL 与统一增量 SQL。 +- 定向验证:`mvn -pl warehouse -am test`。 diff --git a/warehouse-overseas-service/pom.xml b/warehouse-overseas-service/pom.xml new file mode 100644 index 000000000..2fe0001e9 --- /dev/null +++ b/warehouse-overseas-service/pom.xml @@ -0,0 +1,75 @@ + + + 4.0.0 + + com.baiyue.firstleg + baiyue-firstleg-platform + 1.0.0-SNAPSHOT + ../pom.xml + + warehouse-overseas-service + + + + com.baiyue.shared + baiyue-common-openapi + ${project.version} + + + com.baiyue.shared + baiyue-common-audit + ${project.version} + + + com.baomidou + mybatis-plus-spring-boot3-starter + + + com.baiyue.shared + baiyue-common-web + ${project.version} + + + com.baiyue.shared + baiyue-common-security + ${project.version} + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + com.alibaba.cloud + spring-cloud-starter-alibaba-sentinel + + + org.springframework.boot + spring-boot-starter-actuator + + + com.mysql + mysql-connector-j + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/BaiyueWarehouseOverseasApplication.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/BaiyueWarehouseOverseasApplication.java new file mode 100644 index 000000000..1e8de8582 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/BaiyueWarehouseOverseasApplication.java @@ -0,0 +1,14 @@ +package com.baiyue.warehouse; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 仓库微服务启动入口,启用 Spring Boot 自动配置并扫描仓库服务的 MyBatis Mapper。 + */ +@MapperScan("com.baiyue.warehouse.mapper") +@SpringBootApplication(scanBasePackages = "com.baiyue") +public class BaiyueWarehouseOverseasApplication { + public static void main(String[] args) { SpringApplication.run(BaiyueWarehouseOverseasApplication.class, args); } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java new file mode 100644 index 000000000..42efe4c60 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java @@ -0,0 +1,27 @@ +package com.baiyue.warehouse.config; + +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Prevents the warehouse schema migration from ever running against another database. */ +@Configuration(proxyBeanMethods = false) +public class WarehouseDatabaseGuardConfiguration { + private static final Pattern MYSQL_SCHEMA = Pattern.compile( + "^jdbc:mysql://[^/]+/([^?;]+)(?:[?;].*)?$", Pattern.CASE_INSENSITIVE); + + @Bean + static BeanFactoryPostProcessor warehouseDatabaseGuard(Environment environment) { + String url = environment.getProperty("spring.datasource.url", "").trim(); + Matcher matcher = MYSQL_SCHEMA.matcher(url); + if (!matcher.matches() || !"baiyue_warehouse_overseas".equalsIgnoreCase(matcher.group(1))) { + throw new IllegalStateException( + "warehouse-overseas-service requires spring.datasource.url to target the baiyue_warehouse_overseas database"); + } + return beanFactory -> { }; + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java new file mode 100644 index 000000000..5cceb91f5 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java @@ -0,0 +1,25 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.warehouse.dto.DropshipPalletRequests; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.security.InternalServiceTokenGuard; +import com.baiyue.warehouse.service.WarehouseResourceService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/internal/v1/warehouse-pallets") +public class InternalWarehousePalletController { + @Resource private WarehouseResourceService service; + @Resource private InternalServiceTokenGuard tokenGuard; + + @PostMapping("/ensure-dropship") + public ApiResponse ensure( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody DropshipPalletRequests.Ensure request) { + tokenGuard.verify(token); + return ApiResponse.success(service.ensureDropshipPallet(request.customerId(), request.warehouseId(), request.businessId())); + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java new file mode 100644 index 000000000..1e9c74f77 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java @@ -0,0 +1,70 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.warehouse.dto.WarehouseResourceLockRequests; +import com.baiyue.warehouse.dto.WarehouseResourceLockResponse; +import com.baiyue.warehouse.dto.WarehousePalletScrapResponse; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.security.InternalServiceTokenGuard; +import com.baiyue.warehouse.service.WarehouseResourceLockService; +import com.baiyue.warehouse.service.WarehouseResourceService; +import jakarta.validation.Valid; +import jakarta.annotation.Resource; +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; + +@RestController +@RequestMapping("/internal/v1/warehouse-resource-locks") +public class InternalWarehouseResourceLockController { + @Resource private WarehouseResourceLockService service; + @Resource private WarehouseResourceService resourceService; + @Resource private InternalServiceTokenGuard tokenGuard; + + @PostMapping + public ApiResponse reserve( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody WarehouseResourceLockRequests.ReserveRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(service.reserve(request)); + } + + @PostMapping("/{lockId}/release") + public ApiResponse release( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long lockId, + @Valid @RequestBody WarehouseResourceLockRequests.CloseRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(service.release(request.customerId(), lockId)); + } + + @PostMapping("/{lockId}/scrap") + public ApiResponse scrap( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long lockId, + @Valid @RequestBody WarehouseResourceLockRequests.CloseRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(service.scrap(request.customerId(), lockId)); + } + + @PostMapping("/pallets/{palletId}/scrap") + public ApiResponse scrapPallet( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long palletId, + @Valid @RequestBody WarehouseResourceLockRequests.CloseRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(service.scrapByPallet(request.customerId(), palletId)); + } + + @PostMapping("/default-locations/ensure") + public ApiResponse ensureDefaultLocation( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody WarehouseResourceLockRequests.DefaultLocationRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(resourceService.ensureDefaultVirtualLocation( + request.customerId(), request.warehouseId())); + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java new file mode 100644 index 000000000..ab4d06ec6 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java @@ -0,0 +1,67 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +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 io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 商户端仓库查询接口,仅允许在认证令牌所属租户范围内读取仓库列表和详情。 + */ +@Tag( + name = "Merchant warehouses / 商户仓库", + description = "Merchant read APIs restricted to the tenant in the authenticated JWT. / 商户端只读接口,租户范围取自认证 JWT。" +) +@RestController +@RequestMapping("/api/merchant/v1/warehouses") +public class MerchantWarehouseController { + @Resource + private WarehouseApplicationService service; + + @Operation( + summary = "List available warehouses / 查询可用仓库", + description = "Tenant comes only from the authenticated JWT; clients cannot override it. / 租户仅从认证 JWT 获取,客户端不可覆盖。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid query / 查询参数无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required / 需要登录") + }) + @GetMapping + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid WarehouseRequests.QueryRequest query) { + return ApiResponse.success(service.page(user.tenantId(), query)); + } + + @Operation( + summary = "Get warehouse details / 查询仓库详情", + description = "Returns warehouse details only within the authenticated merchant tenant. / 仅返回当前认证商户租户内的仓库详情。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required / 需要登录"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found in current tenant / 当前租户下仓库不存在") + }) + @GetMapping("/{id}") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) + @PathVariable long id) { + return ApiResponse.success(service.detail(user.tenantId(), id)); + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java new file mode 100644 index 000000000..0ba243967 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java @@ -0,0 +1,109 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.dto.LocationRequests; +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 jakarta.validation.Valid; +import jakarta.annotation.Resource; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/merchant/v1/warehouses/{warehouseId}") +public class MerchantWarehouseResourceController { + @Resource private WarehouseResourceService service; + + @PostMapping("/locations") + public ApiResponse createLocation(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, + @Valid @RequestBody LocationRequests.SaveRequest request) { + return ApiResponse.success(service.createLocation(tenant(user), warehouseId, request)); + } + + @PutMapping("/locations/{id}") + public ApiResponse updateLocation(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id, + @Valid @RequestBody LocationRequests.SaveRequest request) { + service.updateLocation(tenant(user), warehouseId, id, request); + return ApiResponse.success(null); + } + + @GetMapping("/locations") + public ApiResponse> locations( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId, + @Valid LocationRequests.QueryRequest query) { + return ApiResponse.success(service.locations(tenant(user), warehouseId, query)); + } + + @GetMapping("/locations/{id}") + public ApiResponse location(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, + @PathVariable long id) { + return ApiResponse.success(service.location(tenant(user), warehouseId, id)); + } + + @DeleteMapping("/locations/{id}") + public ApiResponse deleteLocation(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id) { + service.deleteLocation(tenant(user), warehouseId, id); + return ApiResponse.success(null); + } + + @PostMapping("/pallets") + public ApiResponse createPallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, + @Valid @RequestBody PalletRequests.SaveRequest request) { + return ApiResponse.success(service.createPallet(tenant(user), warehouseId, request)); + } + + @PutMapping("/pallets/{id}") + public ApiResponse updatePallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id, + @Valid @RequestBody PalletRequests.SaveRequest request) { + service.updatePallet(tenant(user), warehouseId, id, request); + return ApiResponse.success(null); + } + + @GetMapping("/pallets") + public ApiResponse> pallets( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId, + @Valid PalletRequests.QueryRequest query) { + return ApiResponse.success(service.pallets(tenant(user), warehouseId, query)); + } + + @GetMapping("/pallets/{id}") + public ApiResponse pallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id) { + return ApiResponse.success(service.pallet(tenant(user), warehouseId, id)); + } + + @DeleteMapping("/pallets/{id}") + public ApiResponse deletePallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id) { + service.deletePallet(tenant(user), warehouseId, id); + return ApiResponse.success(null); + } + + private static long tenant(AuthenticatedUser user) { + if (user == null || user.tenantId() == null || user.tenantId() <= 0) { + throw new BizException(ResultCode.UNAUTHORIZED, "认证租户缺失"); + } + return user.tenantId(); + } +} 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 new file mode 100644 index 000000000..e8bcfb10f --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java @@ -0,0 +1,166 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +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 io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 平台端仓库管理接口,提供跨租户管理场景下的仓库创建、修改、查询和状态维护能力。 + */ +@Validated +@Tag( + name = "Platform warehouse administration / 平台仓库管理", + description = "Platform APIs for managing warehouses across tenants. / 平台端按租户管理仓库的接口。" +) +@RestController +@RequestMapping("/api/platform/v1/warehouse-admin") +public class PlatformWarehouseController { + @Resource + private WarehouseApplicationService service; + + @Operation( + summary = "Create warehouse / 新建仓库", + description = "Creates a warehouse, address and service capabilities for the target tenant. / 为目标租户创建仓库、地址和服务能力。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Created successfully / 创建成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在") + }) + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody WarehouseRequests.SaveRequest request) { + requirePlatform(user); + requireTenant(request.customerId()); + return ApiResponse.success(service.create(request)); + } + + @Operation( + summary = "Update warehouse / 更新仓库", + description = "Updates the warehouse and replaces its address and capabilities. / 更新仓库,并整体替换地址和服务能力。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Updated successfully / 更新成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access or tenant validation failed / 平台权限或租户校验失败"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在") + }) + @PutMapping("/{id}") + public ApiResponse update( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) + @RequestParam @NotNull Long customerId, + @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) + @PathVariable long id, + @Valid @RequestBody WarehouseRequests.SaveRequest request) { + requirePlatform(user); + requireTenant(customerId); + service.update(customerId, id, request); + return ApiResponse.success(null); + } + + @Operation( + summary = "List tenant warehouses / 查询租户仓库", + description = "Returns a tenant-scoped warehouse page with optional country, capability and status filters. / 按租户分页查询仓库,可按国家、能力和状态筛选。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid query / 查询参数无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限") + }) + @GetMapping + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) + @RequestParam @NotNull Long customerId, + @Valid WarehouseRequests.QueryRequest request) { + requirePlatform(user); + requireTenant(customerId); + return ApiResponse.success(service.page(customerId, request)); + } + + @Operation( + summary = "Get tenant warehouse details / 查询租户仓库详情", + description = "Returns the warehouse, addresses and service capabilities in the target tenant. / 返回目标租户下的仓库、地址及服务能力。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在") + }) + @GetMapping("/{id}") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) + @RequestParam @NotNull Long customerId, + @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) + @PathVariable long id) { + requirePlatform(user); + requireTenant(customerId); + return ApiResponse.success(service.detail(customerId, id)); + } + + @Operation( + summary = "Enable or disable warehouse / 启用或停用仓库", + description = "Changes warehouse status to ACTIVE or INACTIVE. / 将仓库状态切换为 ACTIVE 或 INACTIVE。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Status changed / 状态变更成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid status / 状态无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在") + }) + @PatchMapping("/{id}/status") + public ApiResponse status( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) + @RequestParam @NotNull Long customerId, + @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) + @PathVariable long id, + @Parameter(description = "Target status: ACTIVE or INACTIVE / 目标状态:ACTIVE 或 INACTIVE", example = "ACTIVE", required = true) + @RequestParam String status) { + requirePlatform(user); + requireTenant(customerId); + service.changeStatus(customerId, id, status); + return ApiResponse.success(null); + } + + private static void requirePlatform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equals(user.appCode())) { + throw new BizException(ResultCode.FORBIDDEN, "Platform application required / 仅允许平台端访问"); + } + } + + private static void requireTenant(Long customerId) { + if (customerId == null || customerId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "Target tenant is required / 必须指定有效目标租户"); + } + } +} + 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 new file mode 100644 index 000000000..7e714246b --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java @@ -0,0 +1,131 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.dto.LocationRequests; +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 jakarta.validation.Valid; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.NotNull; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/platform/v1/warehouse-admin/{warehouseId}") +public class PlatformWarehouseResourceController { + @Resource private WarehouseResourceService service; + + @PostMapping("/locations") + 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)); + } + + @PutMapping("/locations/{id}") + 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); + return ApiResponse.success(null); + } + + @GetMapping("/locations") + public ApiResponse> locations( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, + @PathVariable long warehouseId, @Valid LocationRequests.QueryRequest query) { + requirePlatform(user, customerId); + return ApiResponse.success(service.locations(customerId, warehouseId, query)); + } + + @GetMapping("/locations/{id}") + 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)); + } + + @DeleteMapping("/locations/{id}") + public ApiResponse deleteLocation(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @NotNull Long customerId, + @PathVariable long warehouseId, @PathVariable long id) { + requirePlatform(user, customerId); + service.deleteLocation(customerId, warehouseId, id); + return ApiResponse.success(null); + } + + @PostMapping("/pallets") + 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)); + } + + @PutMapping("/pallets/{id}") + 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); + return ApiResponse.success(null); + } + + @GetMapping("/pallets") + public ApiResponse> pallets( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, + @PathVariable long warehouseId, @Valid PalletRequests.QueryRequest query) { + requirePlatform(user, customerId); + return ApiResponse.success(service.pallets(customerId, warehouseId, query)); + } + + @GetMapping("/pallets/{id}") + 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)); + } + + @DeleteMapping("/pallets/{id}") + public ApiResponse deletePallet(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @NotNull Long customerId, + @PathVariable long warehouseId, @PathVariable long id) { + requirePlatform(user, customerId); + service.deletePallet(customerId, warehouseId, id); + return ApiResponse.success(null); + } + + private static void requirePlatform(AuthenticatedUser user, Long customerId) { + if (user == null || !"PLATFORM".equals(user.appCode())) { + 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/controller/WarehouseHealthController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java new file mode 100644 index 000000000..795623301 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java @@ -0,0 +1,26 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 仓库微服务可用性接口,向平台端暴露服务逻辑状态。 + */ +@Tag(name = "Warehouse service status / 仓储服务状态", description = "Warehouse service availability API. / 仓储服务可用性接口。") +@RestController +@RequestMapping("/api/platform/v1/warehouses") +public class WarehouseHealthController { + @Operation(summary = "Get service status / 查询服务状态", description = "Returns the logical status of warehouse-overseas-service. / 返回 warehouse-overseas-service 的逻辑运行状态。") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Service is available / 服务可用") + @GetMapping("/status") + public ApiResponse> status() { + return ApiResponse.success(Map.of("service", "warehouse-overseas-service", "status", "UP")); + } +} + diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/DropshipPalletRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/DropshipPalletRequests.java new file mode 100644 index 000000000..b5b99da39 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/DropshipPalletRequests.java @@ -0,0 +1,9 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; + +public final class DropshipPalletRequests { + private DropshipPalletRequests() {} + public record Ensure(@Positive long customerId, @Positive long warehouseId, @NotBlank String businessId) {} +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/LocationRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/LocationRequests.java new file mode 100644 index 000000000..2013ef02a --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/LocationRequests.java @@ -0,0 +1,39 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; + +public final class LocationRequests { + private LocationRequests() { + } + + public record SaveRequest( + @NotBlank @Size(max = 50) String zoneCode, + @NotBlank @Size(max = 64) String locationCode, + @NotBlank @Size(max = 32) String locationType, + @NotNull @DecimalMin(value = "0.000", inclusive = false) BigDecimal maxLoadKg, + @NotBlank String status, + @Size(max = 500) String remark + ) { + } + + public record QueryRequest( + @Min(1) long page, + @Min(1) @Max(200) long size, + String zoneCode, + String locationType, + String status, + String keyword + ) { + public QueryRequest { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java new file mode 100644 index 000000000..f5238b4ef --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java @@ -0,0 +1,45 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; + +public final class PalletRequests { + private static final BigDecimal DEFAULT_CAPACITY = new BigDecimal("2.0000"); + + private PalletRequests() { + } + + public record SaveRequest( + @Size(max = 128) String palletCode, + @NotBlank @Size(max = 32) String palletType, + @DecimalMin(value = "0.0000", inclusive = false) BigDecimal capacityCbm, + @NotNull @DecimalMin("0.00") BigDecimal unitPrice, + @NotBlank String status, + Long locationId, + @Size(max = 500) String remark + ) { + public SaveRequest { + if (capacityCbm == null) capacityCbm = DEFAULT_CAPACITY; + } + } + + public record QueryRequest( + @Min(1) long page, + @Min(1) @Max(200) long size, + String palletType, + String status, + Long locationId, + String keyword + ) { + public QueryRequest { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseDetailResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseDetailResponse.java new file mode 100644 index 000000000..f9ec1c882 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseDetailResponse.java @@ -0,0 +1,19 @@ +package com.baiyue.warehouse.dto; + +import com.baiyue.warehouse.entity.ServiceCapabilityEntity; +import com.baiyue.warehouse.entity.WarehouseAddressEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** + * 仓库聚合详情响应,组合仓库主数据、地址列表和服务能力列表。 + */ +@Schema(name = "WarehouseDetailResponse", description = "Warehouse aggregate details / 仓库聚合详情") +public record WarehouseDetailResponse( + @Schema(description = "Warehouse master data / 仓库主数据") WarehouseEntity warehouse, + @Schema(description = "Warehouse addresses / 仓库地址列表") List addresses, + @Schema(description = "Warehouse service capabilities / 仓库服务能力列表") List capabilities +) { +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletScrapResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletScrapResponse.java new file mode 100644 index 000000000..386fb007c --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletScrapResponse.java @@ -0,0 +1,4 @@ +package com.baiyue.warehouse.dto; + +public record WarehousePalletScrapResponse(long palletId, long warehouseId, Long locationId, String status) { +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java new file mode 100644 index 000000000..034dde3c6 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java @@ -0,0 +1,117 @@ +package com.baiyue.warehouse.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalTime; +import java.util.List; + +/** + * 仓库接口请求模型容器,集中定义仓库保存、地址、服务能力、查询和状态变更请求契约。 + */ +public final class WarehouseRequests { + private WarehouseRequests() { + } + + @Schema(name = "WarehouseSaveRequest", description = "Warehouse create or update request / 仓库新增或更新请求") + public record SaveRequest( + @Schema(description = "Target tenant ID / 目标租户 ID", example = "10001", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull Long customerId, + @Schema(description = "Tenant-unique warehouse code / 租户内唯一仓库编码", example = "US-LAX-01", maxLength = 50, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 50) String warehouseCode, + @Schema(description = "Warehouse name / 仓库名称", example = "Los Angeles Main Warehouse / 洛杉矶主仓", maxLength = 120, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 120) String warehouseName, + @Schema(description = "Warehouse type / 仓库类型", example = "OVERSEAS", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank String warehouseType, + @Schema(description = "ISO 3166-1 alpha-2 country code / ISO 两位国家代码", example = "US", pattern = "[A-Z]{2}", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Pattern(regexp = "[A-Z]{2}") String countryCode, + @Schema(description = "IANA timezone / IANA 时区", example = "America/Los_Angeles", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank String timezone, + @Schema(description = "Contact name / 联系人姓名", example = "Alex Chen", maxLength = 64) + @Size(max = 64) String contactName, + @Schema(description = "Contact phone / 联系电话", example = "+1-213-555-0100", maxLength = 32) + @Size(max = 32) String contactPhone, + @Schema(description = "Contact email / 联系邮箱", example = "warehouse@example.com") + @Email String contactEmail, + @Schema(description = "Warehouse status / 仓库状态", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank String status, + @Schema(description = "Remark / 备注", example = "Supports appointment receiving / 支持预约入库", maxLength = 500) + @Size(max = 500) String remark, + @Schema(description = "Primary warehouse address / 仓库主地址", requiredMode = Schema.RequiredMode.REQUIRED) + @Valid @NotNull AddressRequest address, + @Schema(description = "Supported service capabilities / 支持的服务能力") + @Valid List capabilities + ) { + } + + @Schema(name = "WarehouseAddressRequest", description = "Warehouse address / 仓库地址") + public record AddressRequest( + @Schema(description = "Address type / 地址类型", example = "WAREHOUSE", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank String addressType, + @Schema(description = "ISO two-letter country code / ISO 两位国家代码", example = "US", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Pattern(regexp = "[A-Z]{2}") String countryCode, + @Schema(description = "State or province / 州或省", example = "California", maxLength = 100) + @Size(max = 100) String stateProvince, + @Schema(description = "City / 城市", example = "Los Angeles", maxLength = 100, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 100) String city, + @Schema(description = "District / 区县", example = "Commerce", maxLength = 100) + @Size(max = 100) String district, + @Schema(description = "Postal code / 邮政编码", example = "90040", maxLength = 20) + @Size(max = 20) String postalCode, + @Schema(description = "Address line 1 / 地址第一行", example = "123 Logistics Ave", maxLength = 255, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 255) String addressLine1, + @Schema(description = "Address line 2 / 地址第二行", example = "Building A", maxLength = 255) + @Size(max = 255) String addressLine2, + @Schema(description = "Latitude / 纬度", example = "34.052235") + BigDecimal latitude, + @Schema(description = "Longitude / 经度", example = "-118.243683") + BigDecimal longitude + ) { + } + + @Schema(name = "WarehouseCapabilityRequest", description = "Warehouse service capability / 仓库服务能力") + public record CapabilityRequest( + @Schema(description = "Capability code / 能力编码", example = "STORAGE", maxLength = 50, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 50) String capabilityCode, + @Schema(description = "Capability name / 能力名称", example = "Storage / 仓储", maxLength = 100, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 100) String capabilityName, + @Schema(description = "Whether enabled / 是否启用", example = "true", defaultValue = "true") + Boolean enabled, + @Schema(description = "Daily cutoff time / 每日截单时间", example = "16:00:00", type = "string", format = "time") + LocalTime cutoffTime, + @Schema(description = "Capability description / 能力说明", example = "Standard pallet storage / 标准托盘仓储", maxLength = 500) + @Size(max = 500) String description + ) { + } + + @Schema(name = "WarehouseQueryRequest", description = "Warehouse page query / 仓库分页查询条件") + public record QueryRequest( + @Schema(description = "Page number, starting at 1 / 页码,从 1 开始", example = "1", defaultValue = "1", minimum = "1") + @Min(1) long page, + @Schema(description = "Page size / 每页条数", example = "20", defaultValue = "20", minimum = "1", maximum = "200") + @Min(1) @Max(200) long size, + @Schema(description = "Country filter / 国家筛选", example = "US") + String countryCode, + @Schema(description = "Enabled capability code filter / 已启用能力编码筛选", example = "STORAGE") + String capabilityCode, + @Schema(description = "Status filter / 状态筛选", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}) + String status + ) { + public QueryRequest { + if (page == 0) { + page = 1; + } + if (size == 0) { + size = 20; + } + } + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockRequests.java new file mode 100644 index 000000000..042ec6dea --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockRequests.java @@ -0,0 +1,25 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; + +public final class WarehouseResourceLockRequests { + private WarehouseResourceLockRequests() {} + + public record ReserveRequest( + @Positive long customerId, + @Positive long warehouseId, + @Positive long palletId, + @Positive long locationId, + @NotBlank String businessType, + @NotBlank String businessId, + @NotBlank String reservationKey + ) {} + + public record CloseRequest(@Positive long customerId) {} + + public record DefaultLocationRequest( + @Positive long customerId, + @Positive long warehouseId + ) {} +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockResponse.java new file mode 100644 index 000000000..332b92ba5 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockResponse.java @@ -0,0 +1,25 @@ +package com.baiyue.warehouse.dto; + +import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; + +import java.time.LocalDateTime; + +public record WarehouseResourceLockResponse( + long lockId, + long customerId, + long warehouseId, + long palletId, + long locationId, + String businessType, + String businessId, + String reservationKey, + String status, + LocalDateTime lockedAt, + LocalDateTime releasedAt +) { + public static WarehouseResourceLockResponse from(WarehouseResourceLockEntity entity) { + return new WarehouseResourceLockResponse(entity.getId(), entity.getCustomerId(), entity.getWarehouseId(), + entity.getPalletId(), entity.getLocationId(), entity.getBusinessType(), entity.getBusinessId(), + entity.getReservationKey(), entity.getStatus(), entity.getLockedAt(), entity.getReleasedAt()); + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/ServiceCapabilityEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/ServiceCapabilityEntity.java new file mode 100644 index 000000000..2ed0103e1 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/ServiceCapabilityEntity.java @@ -0,0 +1,57 @@ +package com.baiyue.warehouse.entity; + +import lombok.Getter; +import lombok.Setter; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baiyue.common.core.model.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalTime; + +/** + * 仓库服务能力持久化实体,对应 {@code wh_service_capability} 表。 + * 用于记录租户仓库可提供的能力、启用状态和每日截单时间。 + */ +@Schema(name = "WarehouseServiceCapability", description = "仓库服务能力") +@Getter +@Setter +@TableName("wh_service_capability") +public class ServiceCapabilityEntity extends BaseEntity { + @Schema(description = "能力记录编号", example = "190000000000000021") + @TableId(type = IdType.ASSIGN_ID) + private Long id; + @Schema(description = "租户编号", example = "10001") + private Long customerId; + @Schema(description = "仓库编号", example = "190000000000000001") + private Long warehouseId; + @Schema(description = "能力编码", example = "STORAGE") + private String capabilityCode; + @Schema(description = "能力名称", example = "仓储") + private String capabilityName; + @Schema(description = "是否启用", example = "true") + private Boolean enabled; + @Schema(description = "每日截单时间", example = "16:00:00", type = "string", format = "time") + private LocalTime cutoffTime; + @Schema(description = "能力说明", example = "标准托盘仓储") + private String description; + + + + + + + + + + + + + + + + + +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseAddressEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseAddressEntity.java new file mode 100644 index 000000000..e43afda65 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseAddressEntity.java @@ -0,0 +1,77 @@ +package com.baiyue.warehouse.entity; + +import lombok.Getter; +import lombok.Setter; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baiyue.common.core.model.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; + +/** + * 仓库地址持久化实体,对应 {@code wh_warehouse_address} 表。 + * 用于保存租户仓库的结构化地址与经纬度信息。 + */ +@Schema(name = "WarehouseAddress", description = "仓库地址数据") +@Getter +@Setter +@TableName("wh_warehouse_address") +public class WarehouseAddressEntity extends BaseEntity { + @Schema(description = "地址编号", example = "190000000000000011") + @TableId(type = IdType.ASSIGN_ID) + private Long id; + @Schema(description = "租户编号", example = "10001") + private Long customerId; + @Schema(description = "仓库编号", example = "190000000000000001") + private Long warehouseId; + @Schema(description = "地址类型", example = "WAREHOUSE") + private String addressType; + @Schema(description = "ISO 两位国家代码", example = "US") + private String countryCode; + @Schema(description = "州或省", example = "California") + private String stateProvince; + @Schema(description = "城市", example = "Los Angeles") + private String city; + @Schema(description = "区县", example = "Commerce") + private String district; + @Schema(description = "邮政编码", example = "90040") + private String postalCode; + @Schema(description = "地址第一行", example = "123 Logistics Ave") + private String addressLine1; + @Schema(description = "地址第二行", example = "Building A") + private String addressLine2; + @Schema(description = "纬度", example = "34.052235") + private BigDecimal latitude; + @Schema(description = "经度", example = "-118.243683") + private BigDecimal longitude; + + + + + + + + + + + + + + + + + + + + + + + + + + + +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java new file mode 100644 index 000000000..2a8c43527 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java @@ -0,0 +1,71 @@ +package com.baiyue.warehouse.entity; + +import lombok.Getter; +import lombok.Setter; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baiyue.common.core.model.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 仓库主数据持久化实体,对应 {@code wh_warehouse} 表。 + * 用于记录租户下仓库的编码、类型、国家、联系方式和启停状态。 + */ +@Schema(name = "Warehouse", description = "仓库主数据") +@Getter +@Setter +@TableName("wh_warehouse") +public class WarehouseEntity extends BaseEntity { + @Schema(description = "仓库编号", example = "190000000000000001") + @TableId(type = IdType.ASSIGN_ID) + private Long id; + @Schema(description = "租户编号", example = "10001") + private Long customerId; + @Schema(description = "仓库编码", example = "US-LAX-01") + private String warehouseCode; + @Schema(description = "仓库名称", example = "洛杉矶主仓") + private String warehouseName; + @Schema(description = "仓库类型", example = "OVERSEAS") + private String warehouseType; + @Schema(description = "ISO 两位国家代码", example = "US") + private String countryCode; + @Schema(description = "IANA 时区", example = "America/Los_Angeles") + private String timezone; + @Schema(description = "联系人", example = "陈先生") + private String contactName; + @Schema(description = "联系电话", example = "+1-213-555-0100") + private String contactPhone; + @Schema(description = "联系邮箱", example = "warehouse@example.com") + private String contactEmail; + @Schema(description = "仓库状态", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}) + private String status; + @Schema(description = "备注", example = "支持预约入库") + private String remark; + + + + + + + + + + + + + + + + + + + + + + + + + +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseLocationEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseLocationEntity.java new file mode 100644 index 000000000..9027321f5 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseLocationEntity.java @@ -0,0 +1,26 @@ +package com.baiyue.warehouse.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baiyue.common.core.model.BaseEntity; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; + +@Getter +@Setter +@TableName("wh_location") +public class WarehouseLocationEntity extends BaseEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long customerId; + private Long warehouseId; + private String zoneCode; + private String locationCode; + private String locationType; + private BigDecimal maxLoadKg; + private String status; + private String remark; +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBindingEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBindingEntity.java new file mode 100644 index 000000000..b456960b9 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBindingEntity.java @@ -0,0 +1,19 @@ +package com.baiyue.warehouse.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("wh_pallet_business_binding") +public class WarehousePalletBindingEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private Long customerId; + private Long warehouseId; + private String businessType; + private String businessId; + private Long palletId; + private LocalDateTime createdAt; +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletEntity.java new file mode 100644 index 000000000..d6d356979 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletEntity.java @@ -0,0 +1,27 @@ +package com.baiyue.warehouse.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baiyue.common.core.model.BaseEntity; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; + +@Getter +@Setter +@TableName("wh_pallet") +public class WarehousePalletEntity extends BaseEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long customerId; + private Long warehouseId; + private Long locationId; + private String palletCode; + private String palletType; + private BigDecimal capacityCbm; + private BigDecimal unitPrice; + private String status; + private String remark; +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseResourceLockEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseResourceLockEntity.java new file mode 100644 index 000000000..e401318e9 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseResourceLockEntity.java @@ -0,0 +1,29 @@ +package com.baiyue.warehouse.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +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("wh_resource_lock") +public class WarehouseResourceLockEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long customerId; + private Long warehouseId; + private Long palletId; + private Long locationId; + private String businessType; + private String businessId; + private String reservationKey; + private String status; + private LocalDateTime lockedAt; + private LocalDateTime releasedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/ServiceCapabilityMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/ServiceCapabilityMapper.java new file mode 100644 index 000000000..7f265bccb --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/ServiceCapabilityMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.ServiceCapabilityEntity; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Param; + +/** + * 仓库服务能力 MyBatis-Plus Mapper,负责 {@code wh_service_capability} 表访问及仓库子记录的物理清理。 + */ +public interface ServiceCapabilityMapper extends BaseMapper { + + @Delete("DELETE FROM wh_service_capability WHERE customer_id = #{customerId} AND warehouse_id = #{warehouseId}") + int physicallyDeleteByWarehouse(@Param("customerId") long customerId, + @Param("warehouseId") long warehouseId); +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseAddressMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseAddressMapper.java new file mode 100644 index 000000000..620f0e0c8 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseAddressMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.WarehouseAddressEntity; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Param; + +/** + * 仓库地址 MyBatis-Plus Mapper,负责 {@code wh_warehouse_address} 表访问及仓库子记录的物理清理。 + */ +public interface WarehouseAddressMapper extends BaseMapper { + + @Delete("DELETE FROM wh_warehouse_address WHERE customer_id = #{customerId} AND warehouse_id = #{warehouseId}") + int physicallyDeleteByWarehouse(@Param("customerId") long customerId, + @Param("warehouseId") long warehouseId); +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java new file mode 100644 index 000000000..813b2a69d --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java @@ -0,0 +1,22 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; + +public interface WarehouseLocationMapper extends BaseMapper { + /** + * The unique location-code index makes this safe for concurrent first use of an older warehouse. + */ + @Insert(""" + INSERT IGNORE INTO wh_location + (id, customer_id, warehouse_id, zone_code, location_code, location_type, + max_load_kg, status, remark, deleted, version) + VALUES + (#{location.id}, #{location.customerId}, #{location.warehouseId}, #{location.zoneCode}, + #{location.locationCode}, #{location.locationType}, #{location.maxLoadKg}, + #{location.status}, #{location.remark}, 0, 0) + """) + int insertDefaultVirtualLocationIgnore(@Param("location") WarehouseLocationEntity location); +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseMapper.java new file mode 100644 index 000000000..5c9ef0172 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseMapper.java @@ -0,0 +1,6 @@ +package com.baiyue.warehouse.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.warehouse.entity.WarehouseEntity; + +/** + * 仓库主数据 MyBatis-Plus Mapper,提供 {@code wh_warehouse} 表的单表持久化访问。 + */ +public interface WarehouseMapper extends BaseMapper {} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBindingMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBindingMapper.java new file mode 100644 index 000000000..d0e7b44c5 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBindingMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.warehouse.mapper; + +import com.baiyue.warehouse.entity.WarehousePalletBindingEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +public interface WarehousePalletBindingMapper extends BaseMapper { + @Select("SELECT * FROM wh_pallet_business_binding WHERE customer_id=#{customerId} AND warehouse_id=#{warehouseId} " + + "AND business_type=#{businessType} AND business_id=#{businessId} LIMIT 1") + WarehousePalletBindingEntity find(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId, + @Param("businessType") String businessType, @Param("businessId") String businessId); +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java new file mode 100644 index 000000000..5e2f5ca0d --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java @@ -0,0 +1,7 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.WarehousePalletEntity; + +public interface WarehousePalletMapper extends BaseMapper { +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java new file mode 100644 index 000000000..962a33999 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java @@ -0,0 +1,100 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +public interface WarehouseResourceLockMapper extends BaseMapper { + @Select(""" + SELECT * FROM wh_resource_lock + WHERE customer_id = #{customerId} AND reservation_key = #{reservationKey} + LIMIT 1 + """) + WarehouseResourceLockEntity findByReservationKey(@Param("customerId") long customerId, + @Param("reservationKey") String reservationKey); + + @Select("SELECT * FROM wh_resource_lock WHERE customer_id=#{customerId} AND pallet_id=#{palletId} " + + "AND status='ACTIVE' ORDER BY locked_at DESC LIMIT 1") + WarehouseResourceLockEntity findActiveByPallet(@Param("customerId") long customerId, + @Param("palletId") long palletId); + + @Update(""" + UPDATE wh_pallet + SET status = 'OCCUPIED', location_id = #{locationId}, updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{palletId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId} + AND status = 'IDLE' AND deleted = 0 + """) + int occupyPallet(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId, + @Param("palletId") long palletId, @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_location + SET status = 'OCCUPIED', updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{locationId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId} + AND status = 'IDLE' AND deleted = 0 + """) + int occupyLocation(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId, + @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_pallet + SET status = 'IDLE', location_id = NULL, updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{palletId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId} + AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0 + """) + int releasePallet(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId, + @Param("palletId") long palletId, @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_pallet + SET status = 'IDLE', updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{palletId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId} + AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0 + """) + int releasePalletAtVirtualLocation(@Param("customerId") long customerId, + @Param("warehouseId") long warehouseId, + @Param("palletId") long palletId, + @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_location + SET status = 'IDLE', updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{locationId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId} + AND status = 'OCCUPIED' AND deleted = 0 + """) + int releaseLocation(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId, + @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_pallet + SET status = 'SCRAPPED', location_id = NULL, updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{palletId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId} + AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0 + """) + int scrapPallet(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId, + @Param("palletId") long palletId, @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_pallet + SET status = 'SCRAPPED', updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{palletId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId} + AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0 + """) + int scrapPalletAtVirtualLocation(@Param("customerId") long customerId, + @Param("warehouseId") long warehouseId, + @Param("palletId") long palletId, + @Param("locationId") long locationId); + + @Update("UPDATE wh_pallet SET status='SCRAPPED',updated_at=CURRENT_TIMESTAMP(3),version=version+1 " + + "WHERE customer_id=#{customerId} AND id=#{palletId} AND status='OCCUPIED' AND deleted=0") + int scrapPalletWithoutLock(@Param("customerId") long customerId, @Param("palletId") long palletId); + + @Update(""" + UPDATE wh_resource_lock + SET status = #{status}, released_at = CURRENT_TIMESTAMP(3), updated_at = CURRENT_TIMESTAMP(3) + WHERE id = #{id} AND status = 'ACTIVE' + """) + int closeLock(@Param("id") long id, @Param("status") String status); +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java new file mode 100644 index 000000000..9382e29d2 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java @@ -0,0 +1,112 @@ +package com.baiyue.warehouse.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.ServiceCapabilityEntity; +import com.baiyue.warehouse.entity.WarehouseAddressEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.mapper.ServiceCapabilityMapper; +import com.baiyue.warehouse.mapper.WarehouseAddressMapper; +import com.baiyue.warehouse.mapper.WarehouseMapper; +import com.baiyue.warehouse.mapper.WarehouseLocationMapper; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; +import jakarta.annotation.Resource; + +import java.util.List; + +/** + * 仓库聚合持久化仓储,组合仓库、地址和服务能力 Mapper,并强制按租户范围执行查询与写入。 + */ +@Repository +public class WarehouseRepository { + @Resource private WarehouseMapper warehouses; + @Resource private WarehouseLocationMapper locations; + @Resource private WarehouseAddressMapper addresses; + @Resource private ServiceCapabilityMapper capabilities; + + public void insertWarehouse(WarehouseEntity warehouse) { + warehouses.insert(warehouse); + } + + public void insertLocation(WarehouseLocationEntity location) { + locations.insert(location); + } + + public boolean existsLocationCode(long customerId, long warehouseId, String locationCode) { + return locations.selectCount(new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getCustomerId, customerId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getLocationCode, locationCode)) > 0; + } + + public void saveWarehouse(WarehouseEntity warehouse) { + warehouses.updateById(warehouse); + } + + public WarehouseEntity findOwned(long customerId, long id) { + return warehouses.selectOne(new LambdaQueryWrapper() + .eq(WarehouseEntity::getCustomerId, customerId) + .eq(WarehouseEntity::getId, id)); + } + + public boolean existsCode(long customerId, String code, Long excludedId) { + return warehouses.selectCount(new LambdaQueryWrapper() + .eq(WarehouseEntity::getCustomerId, customerId) + .eq(WarehouseEntity::getWarehouseCode, code) + .ne(excludedId != null, WarehouseEntity::getId, excludedId)) > 0; + } + + public PageResult page(long customerId, WarehouseRequests.QueryRequest query) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(WarehouseEntity::getCustomerId, customerId) + .eq(StringUtils.hasText(query.status()), WarehouseEntity::getStatus, query.status()) + .eq(StringUtils.hasText(query.countryCode()), WarehouseEntity::getCountryCode, query.countryCode()) + .orderByDesc(WarehouseEntity::getCreatedAt); + + if (StringUtils.hasText(query.capabilityCode())) { + List ids = capabilities.selectList(new LambdaQueryWrapper() + .select(ServiceCapabilityEntity::getWarehouseId) + .eq(ServiceCapabilityEntity::getCustomerId, customerId) + .eq(ServiceCapabilityEntity::getCapabilityCode, query.capabilityCode()) + .eq(ServiceCapabilityEntity::getEnabled, true)) + .stream() + .map(ServiceCapabilityEntity::getWarehouseId) + .distinct() + .toList(); + if (ids.isEmpty()) { + return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); + } + wrapper.in(WarehouseEntity::getId, ids); + } + return PageResult.from(warehouses.selectPage(new Page<>(query.page(), query.size()), wrapper)); + } + + public List findAddresses(long customerId, long warehouseId) { + return addresses.selectList(new LambdaQueryWrapper() + .eq(WarehouseAddressEntity::getCustomerId, customerId) + .eq(WarehouseAddressEntity::getWarehouseId, warehouseId)); + } + + public List findCapabilities(long customerId, long warehouseId) { + return capabilities.selectList(new LambdaQueryWrapper() + .eq(ServiceCapabilityEntity::getCustomerId, customerId) + .eq(ServiceCapabilityEntity::getWarehouseId, warehouseId)); + } + + public void physicallyDeleteChildren(long customerId, long warehouseId) { + addresses.physicallyDeleteByWarehouse(customerId, warehouseId); + capabilities.physicallyDeleteByWarehouse(customerId, warehouseId); + } + + public void insertAddress(WarehouseAddressEntity address) { + addresses.insert(address); + } + + public void insertCapability(ServiceCapabilityEntity capability) { + capabilities.insert(capability); + } +} 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 new file mode 100644 index 000000000..03933ca81 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java @@ -0,0 +1,90 @@ +package com.baiyue.warehouse.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +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 org.springframework.stereotype.Repository; +import jakarta.annotation.Resource; + +@Repository +public class WarehouseResourceLockRepository { + @Resource private WarehouseResourceLockMapper locks; + @Resource private WarehousePalletMapper pallets; + @Resource private WarehouseLocationMapper locations; + + public WarehouseResourceLockEntity findByReservationKey(long customerId, String reservationKey) { + return locks.findByReservationKey(customerId, reservationKey); + } + + public WarehouseResourceLockEntity find(long customerId, long lockId) { + return locks.selectOne(new LambdaQueryWrapper() + .eq(WarehouseResourceLockEntity::getCustomerId, customerId) + .eq(WarehouseResourceLockEntity::getId, lockId)); + } + + public WarehouseResourceLockEntity findActiveByPallet(long customerId, long palletId) { + return locks.findActiveByPallet(customerId, palletId); + } + + public WarehousePalletEntity findPallet(long customerId, long palletId) { + return pallets.selectOne(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, customerId) + .eq(WarehousePalletEntity::getId, palletId)); + } + + public WarehousePalletEntity findPallet(long customerId, long warehouseId, long palletId) { + return pallets.selectOne(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, customerId) + .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::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getId, locationId)); + } + + public int occupyPallet(long customerId, long warehouseId, long palletId, long locationId) { + return locks.occupyPallet(customerId, warehouseId, palletId, locationId); + } + + public int occupyLocation(long customerId, long warehouseId, long locationId) { + return locks.occupyLocation(customerId, 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()); + } + + public int releasePalletAtVirtualLocation(WarehouseResourceLockEntity lock) { + return locks.releasePalletAtVirtualLocation(lock.getCustomerId(), lock.getWarehouseId(), + lock.getPalletId(), lock.getLocationId()); + } + + public int scrapPallet(WarehouseResourceLockEntity lock) { + return locks.scrapPallet(lock.getCustomerId(), lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId()); + } + + public int scrapPalletWithoutLock(long customerId, long palletId) { + return locks.scrapPalletWithoutLock(customerId, palletId); + } + + public int scrapPalletAtVirtualLocation(WarehouseResourceLockEntity lock) { + return locks.scrapPalletAtVirtualLocation(lock.getCustomerId(), lock.getWarehouseId(), + lock.getPalletId(), lock.getLocationId()); + } + + public int releaseLocation(WarehouseResourceLockEntity lock) { + return locks.releaseLocation(lock.getCustomerId(), 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 new file mode 100644 index 000000000..7c02c7dbf --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java @@ -0,0 +1,158 @@ +package com.baiyue.warehouse.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.warehouse.dto.LocationRequests; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.mapper.WarehouseLocationMapper; +import com.baiyue.warehouse.mapper.WarehouseMapper; +import com.baiyue.warehouse.mapper.WarehousePalletMapper; +import com.baiyue.warehouse.mapper.WarehousePalletBindingMapper; +import com.baiyue.warehouse.entity.WarehousePalletBindingEntity; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; +import jakarta.annotation.Resource; + +@Repository +public class WarehouseResourceRepository { + @Resource private WarehouseMapper warehouses; + @Resource private WarehouseLocationMapper locations; + @Resource private WarehousePalletMapper pallets; + @Resource private WarehousePalletBindingMapper bindings; + + public WarehousePalletBindingEntity findPalletBinding(long customerId, long warehouseId, + String businessType, String businessId) { + return bindings.find(customerId, warehouseId, businessType, businessId); + } + + public void insertPalletBinding(WarehousePalletBindingEntity entity) { + bindings.insert(entity); + } + + public boolean warehouseOwned(long customerId, long warehouseId) { + return warehouses.selectCount(new LambdaQueryWrapper() + .eq(WarehouseEntity::getCustomerId, customerId) + .eq(WarehouseEntity::getId, warehouseId)) > 0; + } + + public WarehouseEntity findWarehouse(long customerId, long warehouseId) { + return warehouses.selectOne(new LambdaQueryWrapper() + .eq(WarehouseEntity::getCustomerId, customerId) + .eq(WarehouseEntity::getId, warehouseId)); + } + + public WarehouseLocationEntity findDefaultVirtualLocation(long customerId, long warehouseId) { + return locations.selectOne(new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getCustomerId, customerId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getLocationType, "VIRTUAL") + .eq(WarehouseLocationEntity::getStatus, "ACTIVE") + .orderByAsc(WarehouseLocationEntity::getCreatedAt) + .last("LIMIT 1")); + } + + public WarehouseLocationEntity findLocationByCode(long customerId, long warehouseId, String locationCode) { + return locations.selectOne(new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getCustomerId, customerId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getLocationCode, locationCode)); + } + + public void insertLocation(WarehouseLocationEntity entity) { + locations.insert(entity); + } + + public int insertDefaultVirtualLocationIgnore(WarehouseLocationEntity entity) { + return locations.insertDefaultVirtualLocationIgnore(entity); + } + + public void updateLocation(WarehouseLocationEntity entity) { + locations.updateById(entity); + } + + public WarehouseLocationEntity findLocation(long customerId, long warehouseId, long id) { + return locations.selectOne(new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getCustomerId, customerId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getId, id)); + } + + public boolean locationCodeExists(long customerId, long warehouseId, String code, Long excludedId) { + return locations.selectCount(new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getCustomerId, customerId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getLocationCode, code) + .ne(excludedId != null, WarehouseLocationEntity::getId, excludedId)) > 0; + } + + public PageResult pageLocations(long customerId, long warehouseId, + LocationRequests.QueryRequest query) { + var wrapper = new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getCustomerId, customerId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(StringUtils.hasText(query.zoneCode()), WarehouseLocationEntity::getZoneCode, query.zoneCode()) + .eq(StringUtils.hasText(query.locationType()), WarehouseLocationEntity::getLocationType, query.locationType()) + .eq(StringUtils.hasText(query.status()), WarehouseLocationEntity::getStatus, query.status()) + .and(StringUtils.hasText(query.keyword()), q -> q + .like(WarehouseLocationEntity::getLocationCode, query.keyword()) + .or().like(WarehouseLocationEntity::getZoneCode, query.keyword())) + .orderByAsc(WarehouseLocationEntity::getZoneCode) + .orderByAsc(WarehouseLocationEntity::getLocationCode); + return PageResult.from(locations.selectPage(new Page<>(query.page(), query.size()), wrapper)); + } + + public void deleteLocation(long id) { + locations.deleteById(id); + } + + public boolean locationHasPallets(long customerId, long warehouseId, long locationId) { + return pallets.selectCount(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, customerId) + .eq(WarehousePalletEntity::getWarehouseId, warehouseId) + .eq(WarehousePalletEntity::getLocationId, locationId)) > 0; + } + + public void insertPallet(WarehousePalletEntity entity) { + pallets.insert(entity); + } + + public void updatePallet(WarehousePalletEntity entity) { + pallets.updateById(entity); + } + + public WarehousePalletEntity findPallet(long customerId, long warehouseId, long id) { + return pallets.selectOne(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, customerId) + .eq(WarehousePalletEntity::getWarehouseId, warehouseId) + .eq(WarehousePalletEntity::getId, id)); + } + + public boolean palletCodeExists(long customerId, long warehouseId, String code, Long excludedId) { + return pallets.selectCount(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, customerId) + .eq(WarehousePalletEntity::getWarehouseId, warehouseId) + .eq(WarehousePalletEntity::getPalletCode, code) + .ne(excludedId != null, WarehousePalletEntity::getId, excludedId)) > 0; + } + + public PageResult pagePallets(long customerId, long warehouseId, + PalletRequests.QueryRequest query) { + var wrapper = new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, customerId) + .eq(WarehousePalletEntity::getWarehouseId, warehouseId) + .eq(StringUtils.hasText(query.palletType()), WarehousePalletEntity::getPalletType, query.palletType()) + .eq(StringUtils.hasText(query.status()), WarehousePalletEntity::getStatus, query.status()) + .eq(query.locationId() != null, WarehousePalletEntity::getLocationId, query.locationId()) + .like(StringUtils.hasText(query.keyword()), WarehousePalletEntity::getPalletCode, query.keyword()) + .orderByDesc(WarehousePalletEntity::getCreatedAt); + return PageResult.from(pallets.selectPage(new Page<>(query.page(), query.size()), wrapper)); + } + + public void deletePallet(long id) { + pallets.deleteById(id); + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/InternalServiceTokenGuard.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/InternalServiceTokenGuard.java new file mode 100644 index 000000000..34ec759d3 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/InternalServiceTokenGuard.java @@ -0,0 +1,26 @@ +package com.baiyue.warehouse.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@Component +public class InternalServiceTokenGuard { + private final byte[] expected; + + public InternalServiceTokenGuard( + @Value("${baiyue.internal-service.token:phase1-local-internal-token-at-least-32-bytes}") String token) { + this.expected = token.getBytes(StandardCharsets.UTF_8); + } + + public void verify(String provided) { + byte[] actual = provided == null ? new byte[0] : provided.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(expected, actual)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid internal service token"); + } + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java new file mode 100644 index 000000000..a10c1367c --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java @@ -0,0 +1,176 @@ +package com.baiyue.warehouse.service; +import jakarta.annotation.Resource; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.ServiceCapabilityEntity; +import com.baiyue.warehouse.entity.WarehouseAddressEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.repository.WarehouseRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.math.BigDecimal; + +/** + * 仓库应用服务,负责仓库主数据、地址和服务能力的租户级用例编排、事务控制与业务校验。 + */ +@Service +public class WarehouseApplicationService { + @Resource + private WarehouseRepository repository; + + + @Transactional + @BusinessAudit( + event = "WAREHOUSE_CREATE", + resourceType = "WAREHOUSE", + resourceId = "#r.warehouseCode", + description = "新建仓库", + captureBefore = false + ) + public Long create(WarehouseRequests.SaveRequest request) { + ensureCodeUnique(request.customerId(), request.warehouseCode(), null); + WarehouseEntity warehouse = toEntity(request); + repository.insertWarehouse(warehouse); + ensureDefaultVirtualLocation(warehouse); + insertChildren(warehouse.getCustomerId(), warehouse.getId(), request.address(), request.capabilities()); + return warehouse.getId(); + } + + private void ensureDefaultVirtualLocation(WarehouseEntity warehouse) { + String locationCode = "LOC-" + warehouse.getWarehouseCode() + "-DEFAULT"; + if (repository.existsLocationCode(warehouse.getCustomerId(), warehouse.getId(), locationCode)) { + return; + } + WarehouseLocationEntity location = new WarehouseLocationEntity(); + location.setCustomerId(warehouse.getCustomerId()); + location.setWarehouseId(warehouse.getId()); + location.setZoneCode("DEFAULT"); + location.setLocationCode(locationCode); + location.setLocationType("VIRTUAL"); + location.setMaxLoadKg(new BigDecimal("999999999.000")); + location.setStatus("ACTIVE"); + location.setRemark("System default virtual location"); + repository.insertLocation(location); + } + + @Transactional + @BusinessAudit( + event = "WAREHOUSE_UPDATE", + resourceType = "WAREHOUSE", + resourceId = "#id", + description = "更新仓库" + ) + public void update(long customerId, long id, WarehouseRequests.SaveRequest request) { + if (request.customerId() == null || customerId != request.customerId()) { + throw new BizException(ResultCode.FORBIDDEN, "租户不匹配"); + } + WarehouseEntity old = owned(customerId, id); + ensureCodeUnique(customerId, request.warehouseCode(), id); + + WarehouseEntity warehouse = toEntity(request); + warehouse.setId(old.getId()); + repository.saveWarehouse(warehouse); + + repository.physicallyDeleteChildren(customerId, id); + insertChildren(customerId, id, request.address(), request.capabilities()); + } + + public PageResult page(long customerId, WarehouseRequests.QueryRequest query) { + return repository.page(customerId, query); + } + + public WarehouseDetailResponse detail(long customerId, long id) { + WarehouseEntity warehouse = owned(customerId, id); + List addresses = repository.findAddresses(customerId, id); + List capabilities = repository.findCapabilities(customerId, id); + return new WarehouseDetailResponse(warehouse, addresses, capabilities); + } + + @Transactional + @BusinessAudit( + event = "WAREHOUSE_STATUS_CHANGE", + resourceType = "WAREHOUSE", + resourceId = "#id", + description = "启用或停用仓库" + ) + public void changeStatus(long customerId, long id, String status) { + if (!"ACTIVE".equals(status) && !"INACTIVE".equals(status)) { + throw new BizException("无效状态"); + } + WarehouseEntity warehouse = owned(customerId, id); + warehouse.setStatus(status); + repository.saveWarehouse(warehouse); + } + + private WarehouseEntity owned(long customerId, long id) { + WarehouseEntity warehouse = repository.findOwned(customerId, id); + if (warehouse == null) { + throw new BizException(ResultCode.NOT_FOUND, "仓库不存在"); + } + return warehouse; + } + + private void ensureCodeUnique(long customerId, String code, Long excludedId) { + if (repository.existsCode(customerId, code, excludedId)) { + throw new BizException(ResultCode.CONFLICT, "仓库编码已存在"); + } + } + + private WarehouseEntity toEntity(WarehouseRequests.SaveRequest request) { + WarehouseEntity warehouse = new WarehouseEntity(); + warehouse.setCustomerId(request.customerId()); + warehouse.setWarehouseCode(request.warehouseCode()); + warehouse.setWarehouseName(request.warehouseName()); + warehouse.setWarehouseType(request.warehouseType()); + warehouse.setCountryCode(request.countryCode()); + warehouse.setTimezone(request.timezone()); + warehouse.setContactName(request.contactName()); + warehouse.setContactPhone(request.contactPhone()); + warehouse.setContactEmail(request.contactEmail()); + warehouse.setStatus(request.status()); + warehouse.setRemark(request.remark()); + return warehouse; + } + + private void insertChildren(long customerId, long warehouseId, + WarehouseRequests.AddressRequest address, + List requestedCapabilities) { + WarehouseAddressEntity addressEntity = new WarehouseAddressEntity(); + addressEntity.setCustomerId(customerId); + addressEntity.setWarehouseId(warehouseId); + addressEntity.setAddressType(address.addressType()); + addressEntity.setCountryCode(address.countryCode()); + addressEntity.setStateProvince(address.stateProvince()); + addressEntity.setCity(address.city()); + addressEntity.setDistrict(address.district()); + addressEntity.setPostalCode(address.postalCode()); + addressEntity.setAddressLine1(address.addressLine1()); + addressEntity.setAddressLine2(address.addressLine2()); + addressEntity.setLatitude(address.latitude()); + addressEntity.setLongitude(address.longitude()); + repository.insertAddress(addressEntity); + + if (requestedCapabilities == null) { + return; + } + for (WarehouseRequests.CapabilityRequest capability : requestedCapabilities) { + ServiceCapabilityEntity entity = new ServiceCapabilityEntity(); + entity.setCustomerId(customerId); + entity.setWarehouseId(warehouseId); + entity.setCapabilityCode(capability.capabilityCode()); + entity.setCapabilityName(capability.capabilityName()); + entity.setEnabled(capability.enabled() == null || capability.enabled()); + entity.setCutoffTime(capability.cutoffTime()); + entity.setDescription(capability.description()); + repository.insertCapability(entity); + } + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java new file mode 100644 index 000000000..b3ef444ff --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java @@ -0,0 +1,158 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.WarehouseResourceLockRequests; +import com.baiyue.warehouse.dto.WarehouseResourceLockResponse; +import com.baiyue.warehouse.dto.WarehousePalletScrapResponse; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; +import com.baiyue.warehouse.repository.WarehouseResourceLockRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import jakarta.annotation.Resource; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Service +public class WarehouseResourceLockService { + @Resource private WarehouseResourceLockRepository repository; + + @Transactional(isolation = Isolation.READ_COMMITTED) + public WarehouseResourceLockResponse reserve(WarehouseResourceLockRequests.ReserveRequest request) { + WarehouseResourceLockEntity existing = repository.findByReservationKey(request.customerId(), request.reservationKey()); + if (existing != null) return idempotentResult(existing, request); + + WarehousePalletEntity pallet = repository.findPallet(request.customerId(), request.warehouseId(), request.palletId()); + WarehouseLocationEntity location = repository.findLocation(request.customerId(), request.warehouseId(), request.locationId()); + if (pallet == null || location == null) { + WarehouseResourceLockEntity concurrent = + repository.findByReservationKey(request.customerId(), request.reservationKey()); + if (concurrent != null) return idempotentResult(concurrent, request); + throw new BizException(ResultCode.NOT_FOUND, "Pallet or location does not belong to the tenant warehouse"); + } + boolean virtualLocation = isVirtual(location); + boolean locationAvailable = virtualLocation ? "ACTIVE".equals(location.getStatus()) : "IDLE".equals(location.getStatus()); + if (!"IDLE".equals(pallet.getStatus()) || !locationAvailable) { + WarehouseResourceLockEntity concurrent = + repository.findByReservationKey(request.customerId(), request.reservationKey()); + if (concurrent != null) return idempotentResult(concurrent, request); + throw new BizException(ResultCode.CONFLICT, "Only IDLE pallet and location can be reserved"); + } + if (repository.occupyPallet(request.customerId(), request.warehouseId(), request.palletId(), request.locationId()) != 1) { + return concurrentIdempotentOrConflict(request); + } + if (!virtualLocation && repository.occupyLocation(request.customerId(), request.warehouseId(), request.locationId()) != 1) { + throw new BizException(ResultCode.CONFLICT, "Location is no longer IDLE"); + } + + WarehouseResourceLockEntity lock = new WarehouseResourceLockEntity(); + lock.setCustomerId(request.customerId()); + lock.setWarehouseId(request.warehouseId()); + lock.setPalletId(request.palletId()); + lock.setLocationId(request.locationId()); + lock.setBusinessType(request.businessType()); + lock.setBusinessId(request.businessId()); + lock.setReservationKey(request.reservationKey()); + lock.setStatus("ACTIVE"); + lock.setLockedAt(LocalDateTime.now()); + repository.insert(lock); + return WarehouseResourceLockResponse.from(lock); + } + + @Transactional + public WarehouseResourceLockResponse release(long customerId, long lockId) { + WarehouseResourceLockEntity lock = requireLock(customerId, lockId); + if ("RELEASED".equals(lock.getStatus())) return WarehouseResourceLockResponse.from(lock); + requireActive(lock); + boolean virtualLocation = isVirtualLockLocation(lock); + int palletResult = virtualLocation ? repository.releasePalletAtVirtualLocation(lock) : repository.releasePallet(lock); + if (palletResult != 1 || (!virtualLocation && repository.releaseLocation(lock) != 1) + || repository.close(lock, "RELEASED") != 1) { + throw new BizException(ResultCode.CONFLICT, "Resource lock state changed concurrently"); + } + lock.setStatus("RELEASED"); + lock.setReleasedAt(LocalDateTime.now()); + return WarehouseResourceLockResponse.from(lock); + } + + @Transactional + public WarehouseResourceLockResponse scrap(long customerId, long lockId) { + WarehouseResourceLockEntity lock = requireLock(customerId, lockId); + if ("SCRAPPED".equals(lock.getStatus())) return WarehouseResourceLockResponse.from(lock); + requireActive(lock); + boolean virtualLocation = isVirtualLockLocation(lock); + int palletResult = virtualLocation ? repository.scrapPalletAtVirtualLocation(lock) : repository.scrapPallet(lock); + if (palletResult != 1 || (!virtualLocation && repository.releaseLocation(lock) != 1) + || repository.close(lock, "SCRAPPED") != 1) { + throw new BizException(ResultCode.CONFLICT, "Resource lock state changed concurrently"); + } + lock.setStatus("SCRAPPED"); + lock.setReleasedAt(LocalDateTime.now()); + return WarehouseResourceLockResponse.from(lock); + } + + @Transactional + public WarehousePalletScrapResponse scrapByPallet(long customerId, long palletId) { + WarehousePalletEntity pallet = repository.findPallet(customerId, palletId); + if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "Pallet not found"); + if ("SCRAPPED".equals(pallet.getStatus())) { + return new WarehousePalletScrapResponse(palletId, pallet.getWarehouseId(), pallet.getLocationId(), "SCRAPPED"); + } + if (!"OCCUPIED".equals(pallet.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Only an OCCUPIED pallet can be scrapped after shipment"); + } + WarehouseResourceLockEntity lock = repository.findActiveByPallet(customerId, palletId); + if (lock != null) { + scrap(customerId, lock.getId()); + } else if (repository.scrapPalletWithoutLock(customerId, palletId) != 1) { + throw new BizException(ResultCode.CONFLICT, "Pallet state changed concurrently"); + } + return new WarehousePalletScrapResponse(palletId, pallet.getWarehouseId(), pallet.getLocationId(), "SCRAPPED"); + } + + private WarehouseResourceLockResponse concurrentIdempotentOrConflict( + WarehouseResourceLockRequests.ReserveRequest request) { + WarehouseResourceLockEntity existing = repository.findByReservationKey(request.customerId(), request.reservationKey()); + if (existing != null) return idempotentResult(existing, request); + throw new BizException(ResultCode.CONFLICT, "Pallet is no longer IDLE"); + } + + private WarehouseResourceLockResponse idempotentResult(WarehouseResourceLockEntity existing, + WarehouseResourceLockRequests.ReserveRequest request) { + if (existing.getWarehouseId() != request.warehouseId() + || existing.getPalletId() != request.palletId() + || existing.getLocationId() != request.locationId() + || !Objects.equals(existing.getBusinessType(), request.businessType()) + || !Objects.equals(existing.getBusinessId(), request.businessId())) { + throw new BizException(ResultCode.CONFLICT, "Reservation key was already used with different resources"); + } + return WarehouseResourceLockResponse.from(existing); + } + + private WarehouseResourceLockEntity requireLock(long customerId, long lockId) { + WarehouseResourceLockEntity lock = repository.find(customerId, lockId); + if (lock == null) throw new BizException(ResultCode.NOT_FOUND, "Resource lock not found"); + return lock; + } + + private static void requireActive(WarehouseResourceLockEntity lock) { + if (!"ACTIVE".equals(lock.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Resource lock is not ACTIVE"); + } + } + + + private boolean isVirtualLockLocation(WarehouseResourceLockEntity lock) { + WarehouseLocationEntity location = repository.findLocation(lock.getCustomerId(), lock.getWarehouseId(), lock.getLocationId()); + if (location == null) throw new BizException(ResultCode.NOT_FOUND, "Resource lock location not found"); + return isVirtual(location); + } + + private static boolean isVirtual(WarehouseLocationEntity location) { + return "VIRTUAL".equals(location.getLocationType()); + } +} 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 new file mode 100644 index 000000000..b71345643 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -0,0 +1,282 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.LocationRequests; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.entity.WarehousePalletBindingEntity; +import com.baiyue.warehouse.repository.WarehouseResourceRepository; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import jakarta.annotation.Resource; + +import java.util.Set; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import org.springframework.util.StringUtils; + +@Service +public class WarehouseResourceService { + private static final Set LOCATION_STATUSES = Set.of("ACTIVE", "IDLE", "OCCUPIED", "INACTIVE"); + private static final Set PALLET_STATUSES = Set.of("IDLE", "OCCUPIED", "SCRAPPED"); + + @Resource private WarehouseResourceRepository repository; + + @Transactional + public Long createLocation(long customerId, long warehouseId, LocationRequests.SaveRequest request) { + requireWarehouse(customerId, warehouseId); + validateLocation(request, customerId, warehouseId, null); + WarehouseLocationEntity entity = locationEntity(customerId, warehouseId, request); + repository.insertLocation(entity); + return entity.getId(); + } + + @Transactional + public void updateLocation(long customerId, long warehouseId, long id, LocationRequests.SaveRequest request) { + WarehouseLocationEntity existing = requireLocation(customerId, warehouseId, id); + if ("VIRTUAL".equals(existing.getLocationType()) + && (!"VIRTUAL".equals(request.locationType()) || !"ACTIVE".equals(request.status()) + || !existing.getLocationCode().equals(request.locationCode()))) { + throw new BizException(ResultCode.CONFLICT, "Default virtual location must remain VIRTUAL and ACTIVE"); + } + validateLocation(request, customerId, warehouseId, id); + WarehouseLocationEntity entity = locationEntity(customerId, warehouseId, request); + entity.setId(existing.getId()); + repository.updateLocation(entity); + } + + public WarehouseLocationEntity location(long customerId, long warehouseId, long id) { + requireWarehouse(customerId, warehouseId); + return requireLocation(customerId, warehouseId, id); + } + + public PageResult locations(long customerId, long warehouseId, + LocationRequests.QueryRequest query) { + requireWarehouse(customerId, warehouseId); + return repository.pageLocations(customerId, warehouseId, query); + } + + @Transactional + public void deleteLocation(long customerId, long warehouseId, long id) { + WarehouseLocationEntity location = requireLocation(customerId, warehouseId, id); + if ("VIRTUAL".equals(location.getLocationType())) { + throw new BizException(ResultCode.CONFLICT, "Default virtual location cannot be deleted"); + } + if (repository.locationHasPallets(customerId, warehouseId, id)) { + throw new BizException(ResultCode.CONFLICT, "库位仍绑定托盘,不能删除"); + } + repository.deleteLocation(id); + } + + @Transactional + public Long createPallet(long customerId, long warehouseId, PalletRequests.SaveRequest request) { + WarehouseEntity warehouse = requireWarehouseEntity(customerId, warehouseId); + validatePalletStatus(request.status()); + Long palletId = IdWorker.getId(); + String palletCode = StringUtils.hasText(request.palletCode()) ? request.palletCode().trim() + : generatedPalletCode(warehouse.getWarehouseCode(), palletId); + Long locationId = request.locationId(); + if (locationId == null) { + locationId = ensureDefaultVirtualLocation(customerId, warehouseId).getId(); + } + validatePallet(request, palletCode, locationId, customerId, warehouseId, null); + WarehousePalletEntity entity = palletEntity(customerId, warehouseId, request, palletCode, locationId); + entity.setId(palletId); + repository.insertPallet(entity); + return entity.getId(); + } + + @Transactional + public WarehousePalletEntity ensureDropshipPallet(long customerId, long warehouseId, String businessId) { + WarehousePalletBindingEntity existing = repository.findPalletBinding(customerId, warehouseId, "DROPSHIP", businessId); + if (existing != null) return requirePallet(customerId, warehouseId, existing.getPalletId()); + Long palletId = createPallet(customerId, warehouseId, + new PalletRequests.SaveRequest(null, "DROPSHIP", null, java.math.BigDecimal.ZERO, "IDLE", null, + "Default dropship pallet for " + businessId)); + WarehousePalletBindingEntity binding = new WarehousePalletBindingEntity(); + binding.setId(IdWorker.getId()); binding.setCustomerId(customerId); binding.setWarehouseId(warehouseId); + binding.setBusinessType("DROPSHIP"); binding.setBusinessId(businessId); binding.setPalletId(palletId); + binding.setCreatedAt(java.time.LocalDateTime.now()); + try { + repository.insertPalletBinding(binding); + return requirePallet(customerId, warehouseId, palletId); + } catch (org.springframework.dao.DuplicateKeyException duplicate) { + WarehousePalletBindingEntity winner = repository.findPalletBinding(customerId, warehouseId, "DROPSHIP", businessId); + if (winner == null) throw duplicate; + return requirePallet(customerId, warehouseId, winner.getPalletId()); + } + } + + /** + * Returns the warehouse-scoped virtual location used only as a pallet/inventory reference. + * It is deliberately never made IDLE/OCCUPIED by resource locking. + */ + @Transactional + public WarehouseLocationEntity ensureDefaultVirtualLocation(long customerId, long warehouseId) { + WarehouseEntity warehouse = requireWarehouseEntity(customerId, warehouseId); + String locationCode = defaultLocationCode(warehouse.getWarehouseCode()); + WarehouseLocationEntity existing = repository.findLocationByCode(customerId, warehouseId, locationCode); + if (existing != null) { + if (!"VIRTUAL".equals(existing.getLocationType()) || !"ACTIVE".equals(existing.getStatus())) { + throw new BizException(ResultCode.CONFLICT, + "Default virtual location code is occupied by a non-active virtual location"); + } + return existing; + } + + WarehouseLocationEntity location = new WarehouseLocationEntity(); + location.setId(IdWorker.getId()); + location.setCustomerId(customerId); + location.setWarehouseId(warehouseId); + location.setZoneCode("DEFAULT"); + location.setLocationCode(locationCode); + location.setLocationType("VIRTUAL"); + location.setMaxLoadKg(new java.math.BigDecimal("999999999.000")); + location.setStatus("ACTIVE"); + location.setRemark("System default virtual location"); + repository.insertDefaultVirtualLocationIgnore(location); + + WarehouseLocationEntity resolved = repository.findLocationByCode(customerId, warehouseId, locationCode); + if (resolved == null) { + throw new BizException(ResultCode.CONFLICT, "Default virtual location could not be created"); + } + return resolved; + } + + @Transactional + public void updatePallet(long customerId, long warehouseId, long id, PalletRequests.SaveRequest request) { + WarehousePalletEntity existing = requirePallet(customerId, warehouseId, id); + if ("SCRAPPED".equals(existing.getStatus()) && !"SCRAPPED".equals(request.status())) { + throw new BizException(ResultCode.CONFLICT, "SCRAPPED pallet cannot be reused"); + } + String palletCode = StringUtils.hasText(request.palletCode()) ? request.palletCode().trim() + : existing.getPalletCode(); + Long locationId = request.locationId() == null ? existing.getLocationId() : request.locationId(); + validatePallet(request, palletCode, locationId, customerId, warehouseId, id); + WarehousePalletEntity entity = palletEntity(customerId, warehouseId, request, palletCode, locationId); + entity.setId(existing.getId()); + repository.updatePallet(entity); + } + + public WarehousePalletEntity pallet(long customerId, long warehouseId, long id) { + requireWarehouse(customerId, warehouseId); + return requirePallet(customerId, warehouseId, id); + } + + public PageResult pallets(long customerId, long warehouseId, + PalletRequests.QueryRequest query) { + requireWarehouse(customerId, warehouseId); + return repository.pagePallets(customerId, warehouseId, query); + } + + @Transactional + public void deletePallet(long customerId, long warehouseId, long id) { + WarehousePalletEntity pallet = requirePallet(customerId, warehouseId, id); + if ("OCCUPIED".equals(pallet.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "占用中的托盘不能删除"); + } + if ("SCRAPPED".equals(pallet.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Scrapped pallet must be retained and cannot be reused"); + } + repository.deletePallet(id); + } + + private void requireWarehouse(long customerId, long warehouseId) { + if (!repository.warehouseOwned(customerId, warehouseId)) { + throw new BizException(ResultCode.NOT_FOUND, "当前租户下仓库不存在"); + } + } + + private WarehouseEntity requireWarehouseEntity(long customerId, long warehouseId) { + WarehouseEntity warehouse = repository.findWarehouse(customerId, warehouseId); + if (warehouse == null) { + throw new BizException(ResultCode.NOT_FOUND, "Tenant warehouse not found"); + } + return warehouse; + } + + private WarehouseLocationEntity requireLocation(long customerId, long warehouseId, long id) { + requireWarehouse(customerId, warehouseId); + WarehouseLocationEntity entity = repository.findLocation(customerId, warehouseId, id); + if (entity == null) throw new BizException(ResultCode.NOT_FOUND, "当前仓库下库位不存在"); + return entity; + } + + private WarehousePalletEntity requirePallet(long customerId, long warehouseId, long id) { + requireWarehouse(customerId, warehouseId); + WarehousePalletEntity entity = repository.findPallet(customerId, warehouseId, id); + if (entity == null) throw new BizException(ResultCode.NOT_FOUND, "当前仓库下托盘不存在"); + return entity; + } + + private void validateLocation(LocationRequests.SaveRequest request, long customerId, + long warehouseId, Long excludedId) { + if (!LOCATION_STATUSES.contains(request.status())) { + throw new BizException("库位状态仅支持 ACTIVE、IDLE、OCCUPIED、INACTIVE"); + } + if (repository.locationCodeExists(customerId, warehouseId, request.locationCode(), excludedId)) { + throw new BizException(ResultCode.CONFLICT, "当前仓库下库位编码已存在"); + } + } + + private void validatePallet(PalletRequests.SaveRequest request, String palletCode, Long locationId, + long customerId, long warehouseId, Long excludedId) { + validatePalletStatus(request.status()); + if (repository.palletCodeExists(customerId, warehouseId, palletCode, excludedId)) { + throw new BizException(ResultCode.CONFLICT, "当前仓库下托盘编码已存在"); + } + if (locationId != null) { + requireLocation(customerId, warehouseId, locationId); + } + } + + private void validatePalletStatus(String status) { + if (!PALLET_STATUSES.contains(status)) { + throw new BizException("托盘状态仅支持 IDLE、OCCUPIED、SCRAPPED"); + } + } + + private WarehouseLocationEntity locationEntity(long customerId, long warehouseId, + LocationRequests.SaveRequest request) { + WarehouseLocationEntity entity = new WarehouseLocationEntity(); + entity.setCustomerId(customerId); + entity.setWarehouseId(warehouseId); + entity.setZoneCode(request.zoneCode()); + entity.setLocationCode(request.locationCode()); + entity.setLocationType(request.locationType()); + entity.setMaxLoadKg(request.maxLoadKg()); + entity.setStatus(request.status()); + entity.setRemark(request.remark()); + return entity; + } + + private WarehousePalletEntity palletEntity(long customerId, long warehouseId, + PalletRequests.SaveRequest request, + String palletCode, Long locationId) { + WarehousePalletEntity entity = new WarehousePalletEntity(); + entity.setCustomerId(customerId); + entity.setWarehouseId(warehouseId); + entity.setLocationId(locationId); + entity.setPalletCode(palletCode); + entity.setPalletType(request.palletType()); + entity.setCapacityCbm(request.capacityCbm()); + entity.setUnitPrice(request.unitPrice()); + entity.setStatus(request.status()); + entity.setRemark(request.remark()); + return entity; + } + + private static String generatedPalletCode(String warehouseCode, long palletId) { + return "PLT-" + warehouseCode + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + + "-" + Long.toUnsignedString(palletId); + } + + private static String defaultLocationCode(String warehouseCode) { + return "LOC-" + warehouseCode + "-DEFAULT"; + } +} diff --git a/warehouse-overseas-service/src/main/resources/application.yml b/warehouse-overseas-service/src/main/resources/application.yml new file mode 100644 index 000000000..7b84bd908 --- /dev/null +++ b/warehouse-overseas-service/src/main/resources/application.yml @@ -0,0 +1,45 @@ +server: + port: ${WAREHOUSE_PORT:9305} +spring: + sql: + init: + mode: always + encoding: UTF-8 + schema-locations: classpath:db/warehouse.sql + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6380} + application: + name: warehouse-overseas-service + config: + import: + - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true + - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + cloud: + nacos: + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} + username: ${NACOS_USERNAME:nacos} + password: ${NACOS_PASSWORD:nacos} + discovery: + enabled: ${NACOS_DISCOVERY_ENABLED:true} + register-enabled: ${NACOS_REGISTER_ENABLED:true} + group: ${NACOS_GROUP:BAIYUE} + namespace: ${NACOS_NAMESPACE:public} + config.namespace: ${NACOS_NAMESPACE:public} + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_warehouse_overseas?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${WAREHOUSE_DB_USERNAME:root} + password: ${WAREHOUSE_DB_PASSWORD:} +management.endpoints.web.exposure.include: health,info,prometheus + +baiyue: + internal-service: + token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} + security: + jwt-mode: jwks + session-mode: claims + issuer: ${JWT_ISSUER:auth-service} + audience: ${JWT_AUDIENCE:lastleg-api} + jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} diff --git a/warehouse-overseas-service/src/main/resources/db/warehouse.sql b/warehouse-overseas-service/src/main/resources/db/warehouse.sql new file mode 100644 index 000000000..7d0cb217e --- /dev/null +++ b/warehouse-overseas-service/src/main/resources/db/warehouse.sql @@ -0,0 +1,259 @@ +-- This script is run through warehouse-overseas-service's datasource only. +-- The datasource guard requires the selected schema to be baiyue_warehouse_overseas; +-- database creation and USE are intentionally forbidden here so a miswired +-- service account cannot create or switch schemas during startup. + +CREATE TABLE IF NOT EXISTS wh_location ( + id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码', + location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码', + location_type VARCHAR(32) NOT NULL COMMENT '库位类型', + max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用', + remark VARCHAR(500) DEFAULT NULL COMMENT '备注', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_location_code (customer_id, warehouse_id, location_code, deleted), + KEY idx_location_query (customer_id, warehouse_id, zone_code, status, deleted) +) ENGINE=InnoDB COMMENT='仓库库位表:按租户和仓库维护库位及承重信息'; + +CREATE TABLE IF NOT EXISTS wh_pallet ( + id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + location_id BIGINT UNSIGNED DEFAULT NULL COMMENT '同仓库内绑定的库位ID,可为空', + pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码', + pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', + capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', + unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', + status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃', + remark VARCHAR(500) DEFAULT NULL COMMENT '备注', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_pallet_code (customer_id, warehouse_id, pallet_code, deleted), + KEY idx_pallet_query (customer_id, warehouse_id, status, deleted), + KEY idx_pallet_location (customer_id, warehouse_id, location_id, deleted) +) ENGINE=InnoDB COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; + +CREATE TABLE IF NOT EXISTS wh_resource_lock ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID', + pallet_id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID', + location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID', + business_type VARCHAR(50) NOT NULL COMMENT '业务类型', + business_id VARCHAR(100) NOT NULL COMMENT '业务记录ID', + reservation_key VARCHAR(160) NOT NULL COMMENT '资源预留幂等键', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '资源锁状态', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + released_at DATETIME(3) DEFAULT 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_resource_lock_reservation (customer_id, reservation_key), + KEY idx_resource_lock_pallet (customer_id, warehouse_id, pallet_id, status), + KEY idx_resource_lock_location (customer_id, warehouse_id, location_id, status), + KEY idx_resource_lock_business (customer_id, business_type, business_id) +) ENGINE=InnoDB COMMENT='仓储资源锁定记录'; + +CREATE TABLE IF NOT EXISTS wh_warehouse ( + id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '仓库所属租户ID', + warehouse_code VARCHAR(50) NOT NULL COMMENT '租户内唯一仓库编码', + warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', + warehouse_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '仓库类型', + country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', + timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地IANA时区', + contact_name VARCHAR(64) DEFAULT NULL COMMENT '仓库联系人', + contact_phone VARCHAR(32) DEFAULT NULL COMMENT '联系电话', + contact_email VARCHAR(128) DEFAULT NULL COMMENT '联系邮箱', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '仓库状态', + remark VARCHAR(500) DEFAULT NULL COMMENT '仓库备注', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_wh_tenant_code (customer_id, warehouse_code, deleted), + KEY idx_wh_tenant_status (customer_id, status, deleted), + KEY idx_wh_country (customer_id, country_code, deleted) +) ENGINE=InnoDB COMMENT='仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态'; + +CREATE TABLE IF NOT EXISTS wh_warehouse_address ( + id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + address_type VARCHAR(20) NOT NULL DEFAULT 'OPERATING' COMMENT '地址类型,例如OPERATING或RETURN', + country_code CHAR(2) NOT NULL COMMENT '国家二字码', + state_province VARCHAR(100) DEFAULT NULL COMMENT '省或州', + city VARCHAR(100) NOT NULL COMMENT '城市', + district VARCHAR(100) DEFAULT NULL COMMENT '区县', + postal_code VARCHAR(20) DEFAULT NULL COMMENT '邮政编码', + address_line1 VARCHAR(255) NOT NULL COMMENT '主地址行', + address_line2 VARCHAR(255) DEFAULT NULL COMMENT '补充地址行', + latitude DECIMAL(10,7) DEFAULT NULL COMMENT '纬度', + longitude DECIMAL(10,7) DEFAULT NULL COMMENT '经度', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_address_type (customer_id, warehouse_id, address_type, deleted), + KEY idx_address_warehouse (customer_id, warehouse_id, deleted) +) ENGINE=InnoDB COMMENT='仓库地址表:保存运营、退货等不同用途的地址及可选经纬度'; + +CREATE TABLE IF NOT EXISTS wh_service_capability ( + id BIGINT UNSIGNED NOT NULL COMMENT '服务能力ID', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + capability_code VARCHAR(50) NOT NULL COMMENT '能力编码:RECEIVING、STORAGE、PICK_PACK、RETURN等', + capability_name VARCHAR(100) NOT NULL COMMENT '能力名称', + enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + cutoff_time TIME DEFAULT NULL COMMENT '当日作业截单时间', + description VARCHAR(500) DEFAULT NULL COMMENT '能力说明', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_capability (customer_id, warehouse_id, capability_code, deleted), + KEY idx_capability_query (customer_id, warehouse_id, enabled, deleted) +) ENGINE=InnoDB COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间'; + +-- 不使用跨服务数据库外键;仓库聚合内的归属由服务代码通过 customer_id 与 warehouse_id 共同校验。 + +-- 修复既有数据库中因历史脚本编码错误或英文初始化产生的表与字段注释。 +ALTER TABLE wh_location COMMENT='仓库库位表:按租户和仓库维护库位及承重信息'; +ALTER TABLE wh_location + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', + MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码', + MODIFY COLUMN location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码', + MODIFY COLUMN location_type VARCHAR(32) NOT NULL COMMENT '库位类型', + MODIFY COLUMN max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +CREATE TABLE IF NOT EXISTS wh_pallet_business_binding ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id BIGINT NOT NULL COMMENT '客户租户ID', warehouse_id BIGINT NOT NULL COMMENT '仓库ID', + business_type VARCHAR(32) NOT NULL COMMENT '业务类型', business_id VARCHAR(128) NOT NULL COMMENT '绑定业务记录ID', + pallet_id BIGINT NOT NULL COMMENT '托盘ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_binding (customer_id,warehouse_id,business_type,business_id), + UNIQUE KEY uk_wh_pallet_binding_pallet (pallet_id) +) ENGINE=InnoDB COMMENT='托盘业务绑定关系'; + +-- Every warehouse has one non-occupiable virtual location used for inventory history. +INSERT INTO wh_location (id, customer_id, warehouse_id, zone_code, location_code, location_type, + max_load_kg, status, remark) +SELECT UUID_SHORT(), w.customer_id, w.id, 'DEFAULT', CONCAT('LOC-', w.warehouse_code, '-DEFAULT'), + 'VIRTUAL', 999999999.000, 'ACTIVE', 'System default virtual location' +FROM wh_warehouse w +WHERE NOT EXISTS ( + SELECT 1 FROM wh_location l + WHERE l.customer_id = w.customer_id AND l.warehouse_id = w.id + AND l.location_code = CONCAT('LOC-', w.warehouse_code, '-DEFAULT') AND l.deleted = 0 +); + +ALTER TABLE wh_pallet COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; +ALTER TABLE wh_pallet + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', + MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN location_id BIGINT UNSIGNED NULL COMMENT '同仓库内绑定的库位ID,可为空', + MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码', + MODIFY COLUMN pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', + MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', + MODIFY COLUMN unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_warehouse COMMENT='仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态'; +ALTER TABLE wh_warehouse + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)', + MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT '仓库所属租户ID', + MODIFY COLUMN warehouse_code VARCHAR(50) NOT NULL COMMENT '租户内唯一仓库编码', + MODIFY COLUMN warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', + MODIFY COLUMN warehouse_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '仓库类型', + MODIFY COLUMN country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', + MODIFY COLUMN timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地IANA时区', + MODIFY COLUMN contact_name VARCHAR(64) NULL COMMENT '仓库联系人', + MODIFY COLUMN contact_phone VARCHAR(32) NULL COMMENT '联系电话', + MODIFY COLUMN contact_email VARCHAR(128) NULL COMMENT '联系邮箱', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '仓库状态', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '仓库备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_warehouse_address COMMENT='仓库地址表:保存运营、退货等不同用途的地址及可选经纬度'; +ALTER TABLE wh_warehouse_address + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID', + MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN address_type VARCHAR(20) NOT NULL DEFAULT 'OPERATING' COMMENT '地址类型:OPERATING运营、RETURN退货', + MODIFY COLUMN country_code CHAR(2) NOT NULL COMMENT '国家二字码', + MODIFY COLUMN state_province VARCHAR(100) NULL COMMENT '省或州', + MODIFY COLUMN city VARCHAR(100) NOT NULL COMMENT '城市', + MODIFY COLUMN district VARCHAR(100) NULL COMMENT '区县', + MODIFY COLUMN postal_code VARCHAR(20) NULL COMMENT '邮政编码', + MODIFY COLUMN address_line1 VARCHAR(255) NOT NULL COMMENT '主地址行', + MODIFY COLUMN address_line2 VARCHAR(255) NULL COMMENT '补充地址行', + MODIFY COLUMN latitude DECIMAL(10,7) NULL COMMENT '纬度', + MODIFY COLUMN longitude DECIMAL(10,7) NULL COMMENT '经度', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_service_capability COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间'; +ALTER TABLE wh_service_capability + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '服务能力ID', + MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN capability_code VARCHAR(50) NOT NULL COMMENT '能力编码:RECEIVING收货、STORAGE仓储、PICK_PACK拣包、RETURN退货', + MODIFY COLUMN capability_name VARCHAR(100) NOT NULL COMMENT '能力名称', + MODIFY COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + MODIFY COLUMN cutoff_time TIME NULL COMMENT '当日作业截单时间', + MODIFY COLUMN description VARCHAR(500) NULL COMMENT '能力说明', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java new file mode 100644 index 000000000..b075a3780 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java @@ -0,0 +1,29 @@ +package com.baiyue.warehouse.config; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class WarehouseDatabaseGuardConfigurationTest { + @Test + void acceptsDedicatedOverseasWarehouseDatabase() { + MockEnvironment environment = new MockEnvironment() + .withProperty("spring.datasource.url", + "jdbc:mysql://127.0.0.1:3306/baiyue_warehouse_overseas?serverTimezone=Asia/Shanghai"); + + assertThatCode(() -> WarehouseDatabaseGuardConfiguration.warehouseDatabaseGuard(environment)) + .doesNotThrowAnyException(); + } + + @Test + void rejectsAnyOtherDatabaseBeforeSchemaInitialization() { + MockEnvironment environment = new MockEnvironment() + .withProperty("spring.datasource.url", "jdbc:mysql://127.0.0.1:3306/baiyue_warehouse"); + + assertThatThrownBy(() -> WarehouseDatabaseGuardConfiguration.warehouseDatabaseGuard(environment)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("baiyue_warehouse_overseas"); + } +} 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 new file mode 100644 index 000000000..4c1f680e1 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java @@ -0,0 +1,69 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.service.WarehouseResourceService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class WarehouseResourceControllerTest { + private final WarehouseResourceService service = mock(WarehouseResourceService.class); + + @Test + void merchantTenantAlwaysComesFromAuthenticatedUser() { + MerchantWarehouseResourceController controller = merchantController(); + AuthenticatedUser merchant = user(10001L, "MERCHANT"); + WarehouseLocationEntity expected = new WarehouseLocationEntity(); + when(service.location(10001L, 20001L, 30001L)).thenReturn(expected); + + controller.location(merchant, 20001L, 30001L); + + verify(service).location(10001L, 20001L, 30001L); + } + + @Test + void platformApiRejectsNonPlatformIdentity() { + PlatformWarehouseResourceController controller = platformController(); + + assertThatThrownBy(() -> controller.location(user(10001L, "MERCHANT"), + 10002L, 20001L, 30001L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("平台端"); + } + + @Test + void platformApiUsesExplicitTargetTenant() { + PlatformWarehouseResourceController controller = platformController(); + WarehouseLocationEntity expected = new WarehouseLocationEntity(); + when(service.location(10002L, 20001L, 30001L)).thenReturn(expected); + + controller.location(user(1L, "PLATFORM"), 10002L, 20001L, 30001L); + + verify(service).location(10002L, 20001L, 30001L); + } + + private static AuthenticatedUser user(long customerId, String appCode) { + return new AuthenticatedUser(1L, customerId, customerId, 1L, + "tester", appCode, "WEB", Set.of()); + } + + private MerchantWarehouseResourceController merchantController() { + MerchantWarehouseResourceController controller = new MerchantWarehouseResourceController(); + ReflectionTestUtils.setField(controller, "service", service); + return controller; + } + + private PlatformWarehouseResourceController platformController() { + PlatformWarehouseResourceController controller = new PlatformWarehouseResourceController(); + ReflectionTestUtils.setField(controller, "service", service); + return controller; + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java new file mode 100644 index 000000000..f59b0735b --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java @@ -0,0 +1,98 @@ +package com.baiyue.warehouse.schema; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SchemaChineseCommentContractTest { + + private static final Pattern TABLE = Pattern.compile( + "(?is)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+(wh_[a-z0-9_]+)" + + "\\s*\\((.*?)\\)\\s*ENGINE\\s*=\\s*InnoDB\\s+COMMENT\\s*=\\s*'([^']*)'\\s*;"); + private static final Pattern CHINESE = Pattern.compile("[\\u4e00-\\u9fff]"); + + @Test + void allWarehouseTablesAndColumnsHaveChineseCommentsAndMigrationCoverage() throws Exception { + String baseline = Files.readString( + Path.of("src", "main", "resources", "db", "warehouse.sql"), StandardCharsets.UTF_8); + String incremental = Files.readString( + Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8); + + List tables = parseAndAssert(baseline, "仓储基线 SQL"); + assertEquals(7, tables.size(), "仓储表数量变化时必须同步表索引和备注迁移"); + parseAndAssert(incremental, "统一增量 SQL"); + + 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() + "'"), + "025 备注迁移缺少表: " + table.name()); + for (String column : table.columns()) { + assertTrue(migration.contains("table_name='" + table.name() + + "' AND column_name='" + column + "'"), + "025 备注迁移缺少字段: " + table.name() + "." + column); + } + } + } + + private static List parseAndAssert(String sql, String source) { + List tables = new ArrayList<>(); + Matcher matcher = TABLE.matcher(sql); + while (matcher.find()) { + String table = matcher.group(1); + assertTrue(CHINESE.matcher(matcher.group(3)).find(), source + " 表备注必须包含中文: " + table); + List columns = new ArrayList<>(); + for (String definition : splitTopLevel(matcher.group(2))) { + String normalized = definition.stripLeading().toUpperCase(); + if (normalized.matches("^(PRIMARY|UNIQUE|KEY|CONSTRAINT|FOREIGN|CHECK)\\b.*")) { + continue; + } + String column = definition.stripLeading().split("\\s+", 2)[0].replace("`", ""); + columns.add(column); + assertTrue(Pattern.compile("(?is)\\bCOMMENT\\s+'[^']*[\\u4e00-\\u9fff][^']*'") + .matcher(definition).find(), + source + " 字段备注必须包含中文: " + table + "." + column); + } + assertFalse(columns.isEmpty(), source + " 表定义没有字段: " + table); + tables.add(new TableDefinition(table, columns)); + } + assertFalse(tables.isEmpty(), source + " 未解析到仓储表"); + return tables; + } + + private static List splitTopLevel(String body) { + List definitions = new ArrayList<>(); + int start = 0; + int parentheses = 0; + boolean quoted = false; + for (int i = 0; i < body.length(); i++) { + char current = body.charAt(i); + if (current == '\'' && (i == 0 || body.charAt(i - 1) != '\\')) { + quoted = !quoted; + } else if (!quoted && current == '(') { + parentheses++; + } else if (!quoted && current == ')') { + parentheses--; + } else if (!quoted && parentheses == 0 && current == ',') { + definitions.add(body.substring(start, i).trim()); + start = i + 1; + } + } + definitions.add(body.substring(start).trim()); + return definitions.stream().filter(value -> !value.isEmpty()).toList(); + } + + private record TableDefinition(String name, List columns) { + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehouseResourceLockSchemaTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehouseResourceLockSchemaTest.java new file mode 100644 index 000000000..639f8cfb7 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehouseResourceLockSchemaTest.java @@ -0,0 +1,24 @@ +package com.baiyue.warehouse.schema; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class WarehouseResourceLockSchemaTest { + @Test + void schemaContainsIdempotentResourceLockContract() throws IOException { + String sql; + try (var stream = getClass().getResourceAsStream("/db/warehouse.sql")) { + assertThat(stream).isNotNull(); + sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + + assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_resource_lock"); + assertThat(sql).contains("UNIQUE KEY uk_resource_lock_reservation (customer_id, reservation_key)"); + assertThat(sql).contains("KEY idx_resource_lock_pallet (customer_id, warehouse_id, pallet_id, status)"); + assertThat(sql).contains("KEY idx_resource_lock_location (customer_id, warehouse_id, location_id, status)"); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/security/InternalServiceTokenGuardTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/security/InternalServiceTokenGuardTest.java new file mode 100644 index 000000000..0f957134e --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/security/InternalServiceTokenGuardTest.java @@ -0,0 +1,32 @@ +package com.baiyue.warehouse.security; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class InternalServiceTokenGuardTest { + private final InternalServiceTokenGuard guard = + new InternalServiceTokenGuard("phase1-local-internal-token-at-least-32-bytes"); + + @Test + void acceptsConfiguredToken() { + assertThatCode(() -> guard.verify("phase1-local-internal-token-at-least-32-bytes")) + .doesNotThrowAnyException(); + } + + @Test + void rejectsMissingOrWrongToken() { + assertUnauthorized(null); + assertUnauthorized("wrong"); + } + + private void assertUnauthorized(String token) { + assertThatThrownBy(() -> guard.verify(token)) + .isInstanceOfSatisfying(ResponseStatusException.class, + error -> assertThat(error.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED)); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java new file mode 100644 index 000000000..8fce21255 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java @@ -0,0 +1,59 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.repository.WarehouseRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class WarehouseApplicationServiceTest { + @Mock WarehouseRepository repository; + private WarehouseApplicationService service; + + @BeforeEach + void setUp() { + service = new WarehouseApplicationService(); + ReflectionTestUtils.setField(service, "repository", repository); + } + + @Test + void warehouseCreationAlsoCreatesActiveDefaultVirtualLocation() { + doAnswer(call -> { + ((WarehouseEntity) call.getArgument(0)).setId(20001L); + return null; + }).when(repository).insertWarehouse(any()); + + service.create(request()); + + ArgumentCaptor location = ArgumentCaptor.forClass(WarehouseLocationEntity.class); + verify(repository).insertLocation(location.capture()); + assertThat(location.getValue().getCustomerId()).isEqualTo(10001L); + assertThat(location.getValue().getWarehouseId()).isEqualTo(20001L); + assertThat(location.getValue().getLocationCode()).isEqualTo("LOC-US-LAX-01-DEFAULT"); + assertThat(location.getValue().getLocationType()).isEqualTo("VIRTUAL"); + assertThat(location.getValue().getStatus()).isEqualTo("ACTIVE"); + } + + private static WarehouseRequests.SaveRequest request() { + WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest( + "WAREHOUSE", "US", "CA", "Los Angeles", null, "90001", + "1 Logistics Ave", null, null, null); + return new WarehouseRequests.SaveRequest(10001L, "US-LAX-01", "LAX Warehouse", + "OVERSEAS", "US", "America/Los_Angeles", null, null, null, + "ACTIVE", null, address, List.of()); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceLockServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceLockServiceTest.java new file mode 100644 index 000000000..a5420b4ba --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceLockServiceTest.java @@ -0,0 +1,263 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.WarehouseResourceLockRequests; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; +import com.baiyue.warehouse.repository.WarehouseResourceLockRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WarehouseResourceLockServiceTest { + private static final long TENANT = 101L; + private static final long WAREHOUSE = 201L; + private static final long PALLET = 301L; + private static final long LOCATION = 401L; + + @Mock WarehouseResourceLockRepository repository; + private WarehouseResourceLockService service; + + @BeforeEach + void setUp() { + service = new WarehouseResourceLockService(); + ReflectionTestUtils.setField(service, "repository", repository); + } + + @Test + void atomicallyOccupiesIdlePalletAndLocation() { + var request = request("reserve-1"); + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("IDLE")); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE")); + when(repository.occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION)).thenReturn(1); + when(repository.occupyLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(1); + doAnswer(call -> { + ((WarehouseResourceLockEntity) call.getArgument(0)).setId(501L); + return null; + }).when(repository).insert(any()); + + var result = service.reserve(request); + + assertThat(result.lockId()).isEqualTo(501L); + assertThat(result.status()).isEqualTo("ACTIVE"); + ArgumentCaptor lock = ArgumentCaptor.forClass(WarehouseResourceLockEntity.class); + verify(repository).insert(lock.capture()); + assertThat(lock.getValue().getReservationKey()).isEqualTo("reserve-1"); + assertThat(lock.getValue().getPalletId()).isEqualTo(PALLET); + assertThat(lock.getValue().getLocationId()).isEqualTo(LOCATION); + } + + @Test + void duplicateReservationReturnsExistingLockWithoutMutatingResources() { + WarehouseResourceLockEntity existing = activeLock(); + when(repository.findByReservationKey(TENANT, "reserve-1")).thenReturn(existing); + + var result = service.reserve(request("reserve-1")); + + assertThat(result.lockId()).isEqualTo(existing.getId()); + verify(repository, never()).occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION); + verify(repository, never()).insert(any()); + } + + @Test + void concurrentDuplicateReturnsExistingAfterResourcesBecomeOccupied() { + WarehouseResourceLockEntity existing = activeLock(); + when(repository.findByReservationKey(TENANT, "reserve-1")).thenReturn(null, existing); + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("OCCUPIED")); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("OCCUPIED")); + + var result = service.reserve(request("reserve-1")); + + assertThat(result.lockId()).isEqualTo(existing.getId()); + verify(repository, never()).occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION); + } + + @Test + void reservationKeyCannotBeReusedForDifferentResources() { + WarehouseResourceLockEntity existing = activeLock(); + existing.setPalletId(999L); + when(repository.findByReservationKey(TENANT, "reserve-1")).thenReturn(existing); + + assertThatThrownBy(() -> service.reserve(request("reserve-1"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("different resources"); + } + + @Test + void rejectsResourceOutsideTenantWarehouse() { + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(null); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE")); + + assertThatThrownBy(() -> service.reserve(request("reserve-1"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("tenant warehouse"); + verify(repository, never()).occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION); + } + + @Test + void rejectsNonIdleResources() { + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("OCCUPIED")); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE")); + + assertThatThrownBy(() -> service.reserve(request("reserve-1"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("Only IDLE"); + } + + @Test + void releaseRestoresBothResourcesAndClosesLock() { + WarehouseResourceLockEntity lock = activeLock(); + when(repository.find(TENANT, lock.getId())).thenReturn(lock); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("OCCUPIED")); + when(repository.releasePallet(lock)).thenReturn(1); + when(repository.releaseLocation(lock)).thenReturn(1); + when(repository.close(lock, "RELEASED")).thenReturn(1); + + var result = service.release(TENANT, lock.getId()); + + assertThat(result.status()).isEqualTo("RELEASED"); + verify(repository).releasePallet(lock); + verify(repository).releaseLocation(lock); + } + + @Test + void scrapDiscardsPalletAndReleasesLocation() { + WarehouseResourceLockEntity lock = activeLock(); + when(repository.find(TENANT, lock.getId())).thenReturn(lock); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("OCCUPIED")); + when(repository.scrapPallet(lock)).thenReturn(1); + when(repository.releaseLocation(lock)).thenReturn(1); + when(repository.close(lock, "SCRAPPED")).thenReturn(1); + + var result = service.scrap(TENANT, lock.getId()); + + assertThat(result.status()).isEqualTo("SCRAPPED"); + verify(repository).scrapPallet(lock); + verify(repository).releaseLocation(lock); + } + + @Test + void concurrentLocationClaimFailsWholeOperation() { + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("IDLE")); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE")); + when(repository.occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION)).thenReturn(1); + when(repository.occupyLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(0); + + assertThatThrownBy(() -> service.reserve(request("reserve-1"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("no longer IDLE"); + verify(repository, never()).insert(any()); + } + + @Test + void virtualLocationIsNeverOccupiedOrReleased() { + WarehouseLocationEntity virtual = location("ACTIVE"); + virtual.setLocationType("VIRTUAL"); + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("IDLE")); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(virtual); + when(repository.occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION)).thenReturn(1); + doAnswer(call -> { + ((WarehouseResourceLockEntity) call.getArgument(0)).setId(502L); + return null; + }).when(repository).insert(any()); + + service.reserve(request("virtual-reserve")); + + verify(repository, never()).occupyLocation(TENANT, WAREHOUSE, LOCATION); + } + + @Test + void scrappingAtVirtualLocationKeepsLocationUntouched() { + WarehouseResourceLockEntity lock = activeLock(); + WarehouseLocationEntity virtual = location("ACTIVE"); + virtual.setLocationType("VIRTUAL"); + when(repository.find(TENANT, lock.getId())).thenReturn(lock); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(virtual); + when(repository.scrapPalletAtVirtualLocation(lock)).thenReturn(1); + when(repository.close(lock, "SCRAPPED")).thenReturn(1); + + var result = service.scrap(TENANT, lock.getId()); + + assertThat(result.status()).isEqualTo("SCRAPPED"); + verify(repository).scrapPalletAtVirtualLocation(lock); + verify(repository, never()).releaseLocation(lock); + } + + @Test + void scrapByPalletClosesItsActiveLockAndKeepsLocationReference() { + WarehousePalletEntity pallet = pallet("OCCUPIED"); + pallet.setWarehouseId(WAREHOUSE); + pallet.setLocationId(LOCATION); + WarehouseResourceLockEntity lock = activeLock(); + when(repository.findPallet(TENANT, PALLET)).thenReturn(pallet); + when(repository.findActiveByPallet(TENANT, PALLET)).thenReturn(lock); + when(repository.find(TENANT, lock.getId())).thenReturn(lock); + WarehouseLocationEntity virtual = location("ACTIVE"); + virtual.setLocationType("VIRTUAL"); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(virtual); + when(repository.scrapPalletAtVirtualLocation(lock)).thenReturn(1); + when(repository.close(lock, "SCRAPPED")).thenReturn(1); + + var result = service.scrapByPallet(TENANT, PALLET); + + assertThat(result.status()).isEqualTo("SCRAPPED"); + assertThat(result.locationId()).isEqualTo(LOCATION); + verify(repository).scrapPalletAtVirtualLocation(lock); + } + + @Test + void scrapByPalletIsIdempotentWhenAlreadyScrapped() { + WarehousePalletEntity pallet = pallet("SCRAPPED"); + pallet.setWarehouseId(WAREHOUSE); + pallet.setLocationId(LOCATION); + when(repository.findPallet(TENANT, PALLET)).thenReturn(pallet); + + assertThat(service.scrapByPallet(TENANT, PALLET).status()).isEqualTo("SCRAPPED"); + verify(repository, never()).findActiveByPallet(TENANT, PALLET); + } + + private static WarehouseResourceLockRequests.ReserveRequest request(String key) { + return new WarehouseResourceLockRequests.ReserveRequest(TENANT, WAREHOUSE, PALLET, LOCATION, + "INBOUND_PLAN_PALLET", "plan-pallet-1", key); + } + + private static WarehousePalletEntity pallet(String status) { + WarehousePalletEntity entity = new WarehousePalletEntity(); + entity.setStatus(status); + return entity; + } + + private static WarehouseLocationEntity location(String status) { + WarehouseLocationEntity entity = new WarehouseLocationEntity(); + entity.setStatus(status); + return entity; + } + + private static WarehouseResourceLockEntity activeLock() { + WarehouseResourceLockEntity lock = new WarehouseResourceLockEntity(); + lock.setId(501L); + lock.setCustomerId(TENANT); + lock.setWarehouseId(WAREHOUSE); + lock.setPalletId(PALLET); + lock.setLocationId(LOCATION); + lock.setBusinessType("INBOUND_PLAN_PALLET"); + lock.setBusinessId("plan-pallet-1"); + lock.setReservationKey("reserve-1"); + lock.setStatus("ACTIVE"); + return lock; + } +} 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 new file mode 100644 index 000000000..778edcddc --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java @@ -0,0 +1,260 @@ +package com.baiyue.warehouse.service; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.LocationRequests; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.repository.WarehouseResourceRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WarehouseResourceServiceTest { + private static final long TENANT = 10001L; + private static final long WAREHOUSE = 20001L; + + @Mock + private WarehouseResourceRepository repository; + + private WarehouseResourceService service; + + @BeforeEach + void setUp() { + service = new WarehouseResourceService(); + ReflectionTestUtils.setField(service, "repository", repository); + } + + @Test + void resourceIdsUseMybatisSnowflakeAssignment() throws NoSuchFieldException { + assertThat(WarehouseLocationEntity.class.getDeclaredField("id").getAnnotation(TableId.class).type()) + .isEqualTo(IdType.ASSIGN_ID); + assertThat(WarehousePalletEntity.class.getDeclaredField("id").getAnnotation(TableId.class).type()) + .isEqualTo(IdType.ASSIGN_ID); + } + + @Test + void createsLocationWithSnowflakeAssignedByMapper() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + doAnswer(invocation -> { + ((WarehouseLocationEntity) invocation.getArgument(0)).setId(2089000000000000001L); + return null; + }).when(repository).insertLocation(org.mockito.ArgumentMatchers.any()); + + Long id = service.createLocation(TENANT, WAREHOUSE, locationRequest("A-01")); + + assertThat(id).isEqualTo(2089000000000000001L); + ArgumentCaptor captor = ArgumentCaptor.forClass(WarehouseLocationEntity.class); + verify(repository).insertLocation(captor.capture()); + assertThat(captor.getValue().getCustomerId()).isEqualTo(TENANT); + assertThat(captor.getValue().getWarehouseId()).isEqualTo(WAREHOUSE); + } + + @Test + void rejectsResourceAccessWhenWarehouseDoesNotBelongToTenant() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(false); + + assertThatThrownBy(() -> service.createLocation(TENANT, WAREHOUSE, locationRequest("A-01"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("仓库不存在"); + verify(repository, never()).insertLocation(org.mockito.ArgumentMatchers.any()); + } + + @Test + void rejectsDuplicateLocationCodeWithinWarehouse() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + when(repository.locationCodeExists(TENANT, WAREHOUSE, "A-01", null)).thenReturn(true); + + assertThatThrownBy(() -> service.createLocation(TENANT, WAREHOUSE, locationRequest("A-01"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("库位编码已存在"); + } + + @Test + void palletDefaultsToTwoCubicMetresAndBindsOwnedLocation() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse()); + WarehouseLocationEntity location = new WarehouseLocationEntity(); + location.setId(30001L); + when(repository.findLocation(TENANT, WAREHOUSE, 30001L)).thenReturn(location); + doAnswer(invocation -> { + ((WarehousePalletEntity) invocation.getArgument(0)).setId(2089000000000000002L); + return null; + }).when(repository).insertPallet(org.mockito.ArgumentMatchers.any()); + + Long id = service.createPallet(TENANT, WAREHOUSE, + new PalletRequests.SaveRequest("PLT-01", "STANDARD", null, + new BigDecimal("12.50"), "IDLE", 30001L, null)); + + assertThat(id).isEqualTo(2089000000000000002L); + ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); + verify(repository).insertPallet(captor.capture()); + assertThat(captor.getValue().getCapacityCbm()).isEqualByComparingTo("2.0000"); + assertThat(captor.getValue().getLocationId()).isEqualTo(30001L); + } + + @Test + void rejectsLocationFromAnotherWarehouseWhenBindingPallet() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse()); + when(repository.findLocation(TENANT, WAREHOUSE, 30002L)).thenReturn(null); + + assertThatThrownBy(() -> service.createPallet(TENANT, WAREHOUSE, + new PalletRequests.SaveRequest("PLT-01", "STANDARD", null, + BigDecimal.ONE, "IDLE", 30002L, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("库位不存在"); + verify(repository, never()).insertPallet(org.mockito.ArgumentMatchers.any()); + } + + @Test + void preventsDeletingLocationThatStillHasPallets() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + when(repository.findLocation(TENANT, WAREHOUSE, 30001L)).thenReturn(new WarehouseLocationEntity()); + when(repository.locationHasPallets(TENANT, WAREHOUSE, 30001L)).thenReturn(true); + + assertThatThrownBy(() -> service.deleteLocation(TENANT, WAREHOUSE, 30001L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("仍绑定托盘"); + verify(repository, never()).deleteLocation(30001L); + } + + @Test + void preventsDeletingOccupiedPallet() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + WarehousePalletEntity pallet = new WarehousePalletEntity(); + pallet.setStatus("OCCUPIED"); + when(repository.findPallet(TENANT, WAREHOUSE, 40001L)).thenReturn(pallet); + + assertThatThrownBy(() -> service.deletePallet(TENANT, WAREHOUSE, 40001L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("占用中的托盘"); + verify(repository, never()).deletePallet(40001L); + } + + @Test + void preventsDeletingScrappedPalletSoItsIdentityCannotBeReused() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + WarehousePalletEntity pallet = new WarehousePalletEntity(); + pallet.setStatus("SCRAPPED"); + when(repository.findPallet(TENANT, WAREHOUSE, 40003L)).thenReturn(pallet); + + assertThatThrownBy(() -> service.deletePallet(TENANT, WAREHOUSE, 40003L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("cannot be reused"); + verify(repository, never()).deletePallet(40003L); + } + + @Test + void rejectsUnknownPalletStatus() { + when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse()); + + assertThatThrownBy(() -> service.createPallet(TENANT, WAREHOUSE, + new PalletRequests.SaveRequest("PLT-01", "STANDARD", null, + BigDecimal.ONE, "BROKEN", null, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("IDLE、OCCUPIED、SCRAPPED"); + } + + private static LocationRequests.SaveRequest locationRequest(String code) { + return new LocationRequests.SaveRequest("A", code, "STORAGE", + new BigDecimal("1000.000"), "ACTIVE", null); + } + + @Test + void generatesRestartSafePalletCodeAndUsesDefaultVirtualLocation() { + when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse()); + WarehouseLocationEntity location = new WarehouseLocationEntity(); + location.setId(30003L); + location.setLocationType("VIRTUAL"); + location.setStatus("ACTIVE"); + when(repository.findLocationByCode(TENANT, WAREHOUSE, "LOC-US-LAX-01-DEFAULT")) + .thenReturn(location); + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + when(repository.findLocation(TENANT, WAREHOUSE, 30003L)).thenReturn(location); + + service.createPallet(TENANT, WAREHOUSE, + new PalletRequests.SaveRequest(null, "STANDARD", null, BigDecimal.ONE, "IDLE", null, null)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); + verify(repository).insertPallet(captor.capture()); + assertThat(captor.getValue().getPalletCode()).matches("PLT-US-LAX-01-[0-9]{8}-[0-9]+"); + assertThat(captor.getValue().getLocationId()).isEqualTo(30003L); + assertThat(captor.getValue().getId()).isNotNull(); + } + + @Test + void defaultVirtualLocationIsInsertedIdempotentlyForAnExistingWarehouse() { + when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse()); + WarehouseLocationEntity resolved = new WarehouseLocationEntity(); + resolved.setId(30004L); + resolved.setLocationType("VIRTUAL"); + resolved.setStatus("ACTIVE"); + when(repository.findLocationByCode(TENANT, WAREHOUSE, "LOC-US-LAX-01-DEFAULT")) + .thenReturn(null, resolved); + + WarehouseLocationEntity result = service.ensureDefaultVirtualLocation(TENANT, WAREHOUSE); + + assertThat(result.getId()).isEqualTo(30004L); + ArgumentCaptor location = ArgumentCaptor.forClass(WarehouseLocationEntity.class); + verify(repository).insertDefaultVirtualLocationIgnore(location.capture()); + assertThat(location.getValue().getLocationCode()).isEqualTo("LOC-US-LAX-01-DEFAULT"); + assertThat(location.getValue().getLocationType()).isEqualTo("VIRTUAL"); + assertThat(location.getValue().getStatus()).isEqualTo("ACTIVE"); + } + + @Test + void rejectsAConflictingDefaultLocationCode() { + when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse()); + WarehouseLocationEntity conflict = new WarehouseLocationEntity(); + conflict.setLocationType("STORAGE"); + conflict.setStatus("ACTIVE"); + when(repository.findLocationByCode(TENANT, WAREHOUSE, "LOC-US-LAX-01-DEFAULT")) + .thenReturn(conflict); + + assertThatThrownBy(() -> service.ensureDefaultVirtualLocation(TENANT, WAREHOUSE)) + .isInstanceOf(BizException.class) + .hasMessageContaining("Default virtual location code"); + verify(repository, never()).insertDefaultVirtualLocationIgnore(org.mockito.ArgumentMatchers.any()); + } + + @Test + void scrappedPalletCannotBeReactivated() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + WarehousePalletEntity existing = new WarehousePalletEntity(); + existing.setId(40002L); + existing.setPalletCode("PLT-OLD"); + existing.setStatus("SCRAPPED"); + when(repository.findPallet(TENANT, WAREHOUSE, 40002L)).thenReturn(existing); + + assertThatThrownBy(() -> service.updatePallet(TENANT, WAREHOUSE, 40002L, + new PalletRequests.SaveRequest(null, "STANDARD", null, BigDecimal.ONE, "IDLE", null, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("cannot be reused"); + } + + private static WarehouseEntity warehouse() { + WarehouseEntity warehouse = new WarehouseEntity(); + warehouse.setId(WAREHOUSE); + warehouse.setCustomerId(TENANT); + warehouse.setWarehouseCode("US-LAX-01"); + return warehouse; + } +}